fix(spec): $between endpoints accept the ISO/clock strings the platform produces (#6571) - #7058
Conversation
…rm produces (#6571) The sibling half of #5685. Both of `$between`'s endpoints declared `number | Date | FieldReference`, while the platform's own producers put a string in them: - `resolveFilterTokens` (`@objectstack/core`) walks INTO arrays, so a token range resolves member by member and every resolver branch returns a string: `['{current_year_start}', '{current_year_end}']` -> `['2026-01-01', '2026-12-31']`. - This package's own `temporal-conformance.ts` corpus states three `$between` cases with string endpoints, including `['08:00:00', '18:00:00']` on a `Field.time` column. - `SqlDriver.coerceFilterValue` already normalises arrays member-wise, and `calendarDayBetweenRewrite` coerces the min and rewrites a bare-calendar-day max into the half-open next-day bound (#3777). Widened in all three places the contract is spelled — `RangeOperatorSchema` (documentation), `FieldOperatorsSchema` (the enforced copy behind `NormalizedFilterSchema`), and the `Filter<T>` helper, where `T` is known so the guard stays type-precise and each endpoint widens independently. Declaration-side and additive: no producer, caller or driver changed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
Fixes #6571
The sibling half of #5685 (PR #6570). Both of
$between's endpoints declarednumber | Date | FieldReference— and the platform's own producers put astring in them. As with the four ordering slots, the declaration did not
merely under-describe reality, it contradicted it; and it did so in the one slot
where a closed interval is the natural spelling of a date window, which is
what makes this the shape an author — an AI author in particular — is most
likely to reach for.
Premise, re-verified against
origin/main(not the issue's line numbers)The issue was filed 2026-08-08 and
filter.zod.tstook a docs-prose merge since,so every claim was re-measured in this worktree at
08863dd18:RangeOperatorSchema.$betweenendpoints lackstringfilter.zod.ts:232-238FieldOperatorsSchema.$betweenendpoints lackstringfilter.zod.ts:422-425Filter< T >guard isT[K] extends number ǀ Date ? [T[K], T[K]] : neverfilter.zod.ts:630packages/core/src/utils/filter-tokens.ts:386Premise valid on all four points.
Evidence the string endpoint is the platform's own output
resolveFilterTokens(
@objectstack/core,filter-tokens.ts) evaluates the{token}grammar andits
walkcarries an explicit array arm, so a tuple comparand is resolvedmember by member. Every branch of that resolver returns a string —
asYmd(…)for a calendar day,
.toISOString()for the sub-day tokens. So{ close_date: { $between: ['{current_year_start}', '{current_year_end}'] } }becomes
{ close_date: { $between: ['2026-01-01', '2026-12-31'] } }, whosetwo endpoints were exactly the type this schema declared it refused.
witness than ComparisonOperatorSchema 的 $gt/$gte/$lt/$lte 不含 string,与平台自己只产出字符串的日期宏解析器相矛盾 #5685 had.
packages/spec/src/data/temporal-conformance.ts, theshared cross-driver expectation table, states three
$betweencases withstring endpoints: a
datetimerange with its{90_days_ago}/{today}tokentwin (
:294), the degenerate single-day range (:303), and{ at: { $between: ['08:00:00', '18:00:00'] } }on aField.timecolumn(
:537). A declaration contradicted by the conformance table in the samepackage is not under-describing reality; it is disagreeing with it.
SqlDriver.coerceFilterValuerecurses through arrays member-wise(
sql-driver.ts:7333,value.map(v => this.coerceFilterValue(table, field, v))),and
calendarDayBetweenRewrite(:7413) coerces the min and rewrites abare-calendar-day max into the half-open
< next-day(max)bound — knex'swhereBetweenbeing inclusive on both ends, it inherits the same rule$ltehas (dashboard 的日期区间上界打在
datetime列上丢失当天数据 —— 默认配置即命中 #3777).What changed — three spellings, per the ruling
The triage promotion of 2026-08-08 settles the direction, verbatim and
untranslated:
RangeOperatorSchema— the documentation copy.z.string()joins bothendpoint unions, plus a docblock on fix(spec): $gt/$gte/$lt/$lte 接受平台自己产出的 ISO 字符串 (#5685) #6570's template and a
RANGE_ENDPOINT_DESCRIPTION.describe()(module-private, mirroringORDERING_COMPARAND_DESCRIPTION, so it stays off the exported API surface).FieldOperatorsSchema.$between— the enforced copy, the oneNormalizedFilterSchemavalidates against andFieldOperatorsis inferredfrom. ComparisonOperatorSchema 的 $gt/$gte/$lt/$lte 不含 string,与平台自己只产出字符串的日期宏解析器相矛盾 #5685 moved the documentation copy first and had to come back for the
reachable one; both move together here.
Filter< T >— the typed half, mirroring the ordering guard slot for slot.Tis known here, so it stays type-precise rather than admittingstringeverywhere: a
Datefield also takes the resolver's ISO strings, astringfield (a
Field.time'08:00:00', an autonumber code) becomes rangeableinstead of collapsing to
never, and anumberfield stays numbers-only.Each endpoint widens independently, so a partially-resolved range
(
[Date, '2026-12-31']) type-checks — which is what a partial macroresolution actually hands the author.
Why a bare
stringand not an ISO refinementSame finding as #6570, re-measured for the tuple: this schema is
field-agnostic (it never sees which column the range applies to); an ISO
refinement would reject the
HH:MM[:SS[.fff]]formfield-value.zod.ts'sCLOCK_TIME_TYPESdeclares NOTDate.parse-able and which the conformance caseabove exercises; and date-only vs full-timestamp is already reconciled
downstream by
calendarDayBetweenRewrite. Endpoint-vs-column correctness is afield-TYPED judgement that already has an owner.
Stated plainly in the docblock: ranging over non-temporal text is permitted
but not promised (the order is the backend collation's), and nothing promises
the two endpoints are ordered relative to each other — an inverted
[max, min]range is well-formed and matches nothing, at every backend.
Why the filter-semantics compile faces need zero changes
The five compile faces of the filter-semantics surface (driver-sql, driver-memory,
driver-turso, the analytics normaliser, the formula matcher) are deliberately
untouched, and that is the correct outcome: this is a declaration-side widening
of a type that no compile face consults. The endpoints were already being
normalised driver-side by column type before ever reaching a predicate —
coerceFilterValuemaps arrays member-wise andcalendarDayBetweenRewriteownsthe calendar-day max — so every filter that validated before still validates and
every filter that compiled before still compiles the same SQL. Re-measured in
this worktree; the sentence still holds.
Scope was held to
$betweenexactly. #6520 ($icontains) is a differentoperator and is explicitly out of this card.
Tests, both directions
packages/spec/src/data/filter.test.ts:range of full ISO instants; the
['08:00:00', '18:00:00']wall-clock rangethe conformance corpus pins; mixed endpoints (
Date+ string, string +{ $field }) proving each union resolves on its own.Dates,{ $field }references.true, a bareobject), and non-two-element arity.
FieldOperatorsSchemaandNormalizedFilterSchema, so the two spellings cannot drift.Filter< Deal >block checked bytsc, not vitest.The rejection cases assert through
safeParseand check the failing issue'spath(['$between', 0]vs['$between', 1]) rather than a baretoThrow():a tuple carries two independent unions, and
toThrow()cannot tell a min-siderefusal from a max-side one. The ADR-0112
code/statusenvelope does notapply — these are Zod parse verdicts on a declaration surface, not runtime
refusals.
Reverse verification (direction predicted first)
Predicted before running: reverting the union widening turns the new
string-endpoint acceptance pins RED, and the
Filter< T >block goes red undertsconly — vitest never type-checks, so the typed pin is green under vitest onthe reverted schema by construction. Result recorded in the dev report.
Gates
node scripts/check-adr-0087-registration.mjs --base origin/main→this PR adds no declared-breaking changeset(additive widening; no D2/D3registration owed).
pnpm --filter @objectstack/spec check:generated→ all 10generated artifacts up to date, nothing regenerated and no generated tree
touched (the operator
.describe()strings do not reachcontent/docs/references/, same as #6570's).scripts/check-nul-bytes.mjsOK.Changeset graded
minoron@objectstack/spec, matching #6570's own changesetfor the same class of widening.
Generated by Claude Code