Skip to content

Commit 7f9d88b

Browse files
committed
wip(core,objectql): comparand door reads core's real-day / ISO rule (#20549)
1 parent cd901d7 commit 7f9d88b

3 files changed

Lines changed: 230 additions & 121 deletions

File tree

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

Lines changed: 94 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,34 @@
4545
* the one rule both drivers read since #20176; before it, a copy in each).
4646
* That function is total on purpose: an input it cannot interpret is returned
4747
* UNCHANGED rather than becoming an invented instant. So, for a STRING, "the
48-
* driver would return it unchanged" IS the definition of uninterpretable, and
49-
* defining it any other way would refuse comparands that work today.
48+
* driver would return it unchanged" is the first half of the definition of
49+
* uninterpretable.
50+
*
51+
* [#20549] The second half is the write door's: a string is also
52+
* uninterpretable when the rule READS it, but not as the value it names, and
53+
* the record validator refuses the same string as a written value for that
54+
* reason. Two readings, both `Date.parse`'s:
55+
*
56+
* - **A calendar day that does not exist.** `Date.parse` rolls
57+
* `"2026-02-30T10:00:00Z"` over to March 2, so the comparand matched the
58+
* row stored on March 2; the `date` rule keeps `"2026-02-30"` as written and
59+
* compares it as text, and PostgreSQL refuses it (`22008`). Only a real
60+
* leading `YYYY-MM-DD` is read now ({@link namesRealCalendarDay}), for a
61+
* `date`, for the day part of a `datetime`, and for the day part of an
62+
* instant on a `time` column.
63+
* - **A `datetime` string outside the ISO 8601 spellings the platform writes**
64+
* ({@link ISO_DATETIME_WRITE_FORM}). `Date.parse` reads `"07/15/2026 10:00"`
65+
* and `"2026/07/15 10:00"` in the SERVER PROCESS's zone, so the rows a filter
66+
* matched were a property of the host; it reads `"07/08/2026"` month-first.
67+
* A bare integer string is not one of them either: the rule reads it as
68+
* epoch milliseconds, so `"2026"` meant two seconds after 1970 and matched
69+
* every later row. Epoch milliseconds stay a comparand as a NUMBER, which no
70+
* one can mistake for a year.
71+
*
72+
* Both halves together are the one rule for a string, and the record
73+
* validator's `date` / `datetime` arm asks this function rather than a copy of
74+
* it — so a string is refused as a comparand exactly when it is refused as a
75+
* written value.
5076
*
5177
* [#20240] One non-string class is uninterpretable by the same test's other
5278
* half — the rule cannot put it in the column's form. On a `date` column a
@@ -67,12 +93,6 @@
6793
* `temporal-storage-form.ts`'s `isOutsideTemporalYearRange`, the one the
6894
* record validator asks of a written value too; this predicate only calls it.
6995
*
70-
* That is why this is not `utcInstantMs` (`@objectstack/spec/data`), which is
71-
* the stricter canonical reader: it rejects a bare epoch-millisecond string and
72-
* every non-ISO spelling `Date.parse` accepts, both of which the drivers read
73-
* correctly today. Refusing those would be a narrowing this card did not ask
74-
* for and no measurement supports.
75-
*
7696
* ## Two things it deliberately does NOT judge
7797
*
7898
* - **Non-string comparands, save the year class above.** A number is epoch
@@ -118,15 +138,67 @@ export function temporalComparandKind(fieldType: unknown): TemporalComparandKind
118138
}
119139

120140
/**
121-
* `temporalStorageForm`'s `datetime` reading, as a yes/no.
141+
* [#20525] [#20549] The ISO 8601 spellings a `datetime` STRING is read in,
142+
* after trimming — the ones the platform itself writes, each read the same on
143+
* every host by the `datetime` storage rule (`temporalStorageForm`):
144+
*
145+
* - `YYYY-MM-DD` — midnight UTC;
146+
* - `YYYY-MM-DDTHH:MM[:SS[.f…]]`, then `Z`, a `±HH:MM` / `±HHMM` offset, or
147+
* nothing — a zone-naive wall clock is read AS UTC (ADR-0074);
148+
* - `YYYY-MM-DD HH:MM[:SS[.f…]]`, zone-naive only — read AS UTC the same way.
149+
*
150+
* Every other spelling is uninterpretable, as a comparand and as a written
151+
* value alike (the record validator asks {@link isUninterpretableTemporalComparand}).
152+
* `Date.parse` reads `"2026/07/15 10:00"`, `"07/15/2026 10:00"` or
153+
* `"15 July 2026 10:00"` in the SERVER PROCESS's zone and `"07/08/2026"`
154+
* month-first, so the instant was a property of the deployment host; the rule
155+
* reads a bare integer string such as `"2026"` as epoch milliseconds
156+
* (`1970-01-01T00:00:02.026Z`), never as the year it looks like. A space
157+
* separator carries no zone because the rule hands such a string to
158+
* `Date.parse` whole, and its non-ISO reading moves
159+
* `"0050-01-01 10:00+01:00"` to 1950. `T` and `Z` are upper case: a zone-naive
160+
* `"…t10:00"` is not the form the rule reads as UTC.
161+
*/
162+
const ISO_DATETIME_WRITE_FORM =
163+
/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?| \d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)?$/;
164+
165+
/**
166+
* [#20525] [#20549] Does the string's leading `YYYY-MM-DD` name a calendar day
167+
* that exists — month 01..12, day 01 to that month's length, February 29 only
168+
* in a leap year? Arithmetic, never a `Date` round trip: `Date.UTC` reads a
169+
* year 0..99 as 1900..1999, and `Date.parse` ROLLS an impossible day over
170+
* (`"2026-02-30"` is March 2), which is the defect this answers. A string with
171+
* no leading day answers `false`.
172+
*/
173+
function namesRealCalendarDay(s: string): boolean {
174+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
175+
if (!m) return false;
176+
const year = Number(m[1]);
177+
const month = Number(m[2]);
178+
const day = Number(m[3]);
179+
if (month < 1 || month > 12 || day < 1) return false;
180+
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
181+
const length = month === 2 ? (leap ? 29 : 28) : month === 4 || month === 6 || month === 9 || month === 11 ? 30 : 31;
182+
return day <= length;
183+
}
184+
185+
/**
186+
* `temporalStorageForm`'s `datetime` reading of a STRING, as a yes/no: one of
187+
* the {@link ISO_DATETIME_WRITE_FORM} spellings, on a calendar day that
188+
* exists, that the rule reads as an instant.
189+
*
190+
* The last step mirrors the rule: a bare `YYYY-MM-DD` is midnight UTC; a
191+
* zone-naive `YYYY-MM-DD[ T]HH:MM[:SS[.fff]]` has its wall clock read AS UTC;
192+
* anything else is handed to `Date.parse` exactly as the drivers hand it over
193+
* — which is what still refuses `T25:00` or a `+99:99` offset, both inside
194+
* the grammar's shape.
122195
*
123-
* Mirrors it step for step: a bare integer in either sign is epoch
124-
* milliseconds; a bare `YYYY-MM-DD` is midnight UTC; a zone-naive
125-
* `YYYY-MM-DD[ T]HH:MM[:SS[.fff]]` has its wall clock read AS UTC; anything
126-
* else is handed to `Date.parse` exactly as the drivers hand it over.
196+
* [#20549] Before the first two steps this read every spelling `Date.parse`
197+
* reads and every bare integer string, which is the comparand door the write
198+
* door was narrower than — see the module note.
127199
*/
128200
function readsAsInstant(s: string): boolean {
129-
if (/^-?\d+$/.test(s)) return Number.isFinite(new Date(Number(s)).getTime());
201+
if (!ISO_DATETIME_WRITE_FORM.test(s) || !namesRealCalendarDay(s)) return false;
130202
const iso = /^\d{4}-\d{2}-\d{2}$/.test(s)
131203
? `${s}T00:00:00.000Z`
132204
: /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/.test(s)
@@ -137,17 +209,19 @@ function readsAsInstant(s: string): boolean {
137209

138210
/**
139211
* `temporalStorageForm`'s `date` reading of a STRING: its leading `YYYY-MM-DD`, and
140-
* nothing else.
212+
* nothing else — [#20549] when that day exists.
141213
*
142214
* Narrower than {@link readsAsInstant} on purpose, because the rule it mirrors
143215
* is narrower: the `date` rule collapses a leading calendar day and returns
144216
* every other string untouched. `2026/07/15` is therefore uninterpretable for a
145217
* `date` column even though `Date.parse` reads it — today it survives to the
146218
* driver and compares as text against `YYYY-MM-DD` values, which is the silent
147-
* wrong answer this door exists to stop.
219+
* wrong answer this door exists to stop. The rule keeps `"2026-02-30"` as
220+
* written, a day that does not exist: compared as text on memory and SQLite
221+
* (`200 []`), and a 500 on PostgreSQL.
148222
*/
149223
function readsAsCalendarDay(s: string): boolean {
150-
return /^\d{4}-\d{2}-\d{2}/.test(s);
224+
return namesRealCalendarDay(s);
151225
}
152226

153227
/**
@@ -165,7 +239,9 @@ function readsAsWallClock(s: string): boolean {
165239
* Is `value` a comparand that a `kind` column's storage rule cannot read?
166240
*
167241
* `true` for a non-empty, non-placeholder STRING that the kind's rule would
168-
* hand back unchanged, and — on a `date` or `datetime` column — for a number,
242+
* hand back unchanged, or [#20549] would read as another value than it names
243+
* (an impossible calendar day, a `datetime` spelling outside
244+
* {@link ISO_DATETIME_WRITE_FORM}), and — on a `date` or `datetime` column — for a number,
169245
* a `Date` or a string the rule reads whose year falls outside 0001..9999
170246
* ([#20264], `isOutsideTemporalYearRange`). Everything else — any other number
171247
* or `Date`, `null`, a `{ $field }` reference, filter structure, the empty

‎packages/objectql/src/temporal-comparand-door.ts‎

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,24 @@
117117
* the predicate this door calls asks it, and so does the record validator's
118118
* write door, so the two doors cannot disagree about a year.
119119
*
120+
* ## [#20549] A value the rule reads as another value than it names
121+
*
122+
* A string comparand is refused here exactly when the record validator refuses
123+
* it as a written value, because both ask `@objectstack/core`'s one predicate.
124+
* Two classes joined it from the write door, measured before this on
125+
* InMemoryDriver and SqlDriver on SQLite and PostgreSQL 16, the process in
126+
* America/New_York, through `engine.find`:
127+
*
128+
* ```
129+
* datetime $eq "2026-02-30T10:00:00Z" 200, the row at 2026-03-02T10:00Z (rolled over)
130+
* datetime $eq "07/15/2026 10:00" 200, the row at 2026-07-15T14:00Z (the process zone)
131+
* date $eq "2026-02-30" 200 [] on memory and SQLite, 500 on PostgreSQL
132+
* ```
133+
*
134+
* These are not junk: the rule reads each one, as the wrong value, so each
135+
* answered rows. Their refusal says so in its own words ({@link misreadClassOf})
136+
* rather than in the junk class's "compare false for EVERY row".
137+
*
120138
* ## [#20263] The third position: `having`
121139
*
122140
* `engine.aggregate` evaluates `having` itself, over the aggregated rows, so no
@@ -340,11 +358,11 @@ function preview(value: unknown): string {
340358
*/
341359
const REMEDY: Record<TemporalComparandKind, string> = {
342360
datetime:
343-
'Write an ISO-8601 instant ("2026-07-15T00:00:00.000Z"), a bare "YYYY-MM-DD" '
344-
+ '(read as midnight UTC), epoch milliseconds, or a relative-date placeholder the '
345-
+ 'resolver knows, e.g. "{30_days_ago}" / "{current_month_start}".',
361+
'Write an ISO-8601 instant ("2026-07-15T00:00:00.000Z") on a calendar day that exists, '
362+
+ 'a bare "YYYY-MM-DD" (read as midnight UTC), epoch milliseconds as a number, or a '
363+
+ 'relative-date placeholder the resolver knows, e.g. "{30_days_ago}" / "{current_month_start}".',
346364
date:
347-
'Write a "YYYY-MM-DD" calendar day, or a relative-date placeholder the resolver '
365+
'Write a "YYYY-MM-DD" calendar day that exists, or a relative-date placeholder the resolver '
348366
+ 'knows, e.g. "{30_days_ago}" / "{current_month_start}".',
349367
time:
350368
'Write an "HH:MM" / "HH:MM:SS" wall clock (timezone-naive, ADR-0053 D-C1).',
@@ -381,6 +399,57 @@ function yearClassOf(hit: UninterpretableTemporalComparand): (typeof YEAR_CLASS)
381399
return YEAR_CLASS[hit.kind];
382400
}
383401

402+
/**
403+
* [#20549] A comparand the storage rule READS, but not as the value it names —
404+
* the two readings the write door refused first, and `@objectstack/core`'s one
405+
* rule now refuses at both doors. Such a comparand does not compare false for
406+
* every row, as junk does: it answers rows, the wrong ones. Each class says
407+
* why, and what the value would have done, per position.
408+
*/
409+
interface MisreadClass {
410+
why: string;
411+
where: string;
412+
having: string;
413+
}
414+
415+
const IMPOSSIBLE_DAY: MisreadClass = {
416+
why: 'whose calendar day does not exist',
417+
where: 'Read as written it rolls over into another day or compares as text, and answers the wrong '
418+
+ 'rows or a database error.',
419+
having: 'Compared with each group, it rolls over into another day or compares as text, and keeps the '
420+
+ 'wrong groups.',
421+
};
422+
423+
function notAnIsoSpelling(kind: TemporalComparandKind): MisreadClass {
424+
const parser = 'Outside those spellings the server\'s own parser decides the instant: a zone-less '
425+
+ 'spelling in the server\'s time zone, a slashed day in a guessed order, a bare integer as epoch '
426+
+ 'milliseconds';
427+
return {
428+
why: `which is not one of the ISO 8601 spellings a ${kind} comparand is read in`,
429+
where: `${parser}. The rows it matched would depend on the host rather than on the filter.`,
430+
having: `${parser}. The groups it kept would depend on the host rather than on the filter.`,
431+
};
432+
}
433+
434+
/**
435+
* [#20549] The misread class of a hit, or `undefined` for a comparand the rule
436+
* cannot read at all (junk, the #8690 class) — for the message only. The
437+
* verdict is core's, and the leading day is judged by asking core's predicate
438+
* of it, so the calendar arithmetic is never re-derived here.
439+
*/
440+
function misreadClassOf(hit: UninterpretableTemporalComparand): MisreadClass | undefined {
441+
if (typeof hit.value !== 'string') return undefined;
442+
const s = hit.value.trim();
443+
const day = /^\d{4}-\d{2}-\d{2}/.exec(s)?.[0];
444+
if (day !== undefined && isUninterpretableTemporalComparand('date', day) && !isOutsideTemporalYearRange(day, 'date')) {
445+
return IMPOSSIBLE_DAY;
446+
}
447+
if (hit.kind !== 'date' && (/^-?\d+$/.test(s) || Number.isFinite(Date.parse(s)))) {
448+
return notAnIsoSpelling(hit.kind);
449+
}
450+
return undefined;
451+
}
452+
384453
/**
385454
* Refuse every comparand a declared temporal field's storage rule cannot read.
386455
*
@@ -417,6 +486,16 @@ export function assertTemporalComparandsInterpretable(
417486
+ `database error. The filter was NOT applied. ${yearClass.remedy}`,
418487
);
419488
}
489+
// [#20549] A comparand the rule reads as another value than it names.
490+
const misread = misreadClassOf(hit);
491+
if (misread) {
492+
throw invalidFilterError(
493+
`${operation}('${object}'): filter on '${hit.field}' compares a declared ${hit.kind} field `
494+
+ `against ${preview(hit.value)} at ${hit.path}, ${misread.why}, so it is not a ${hit.kind} `
495+
+ `value this platform can interpret. ${misread.where} The filter was NOT applied. `
496+
+ REMEDY[hit.kind],
497+
);
498+
}
420499
throw invalidFilterError(
421500
`${operation}('${object}'): filter on '${hit.field}' compares a declared ${hit.kind} `
422501
+ `field against ${preview(hit.value)} at ${hit.path}, which is not a ${hit.kind} value `
@@ -489,6 +568,15 @@ export function assertHavingTemporalComparandsInterpretable(
489568
+ yearClass.remedy,
490569
);
491570
}
571+
// [#20549] The misread classes, in their own words, as on `where`.
572+
const misread = misreadClassOf(hit);
573+
if (misread) {
574+
throw invalidFilterError(
575+
`aggregate('${object}'): ${column} compares against ${preview(hit.value)} at ${hit.path}, `
576+
+ `${misread.why}, so it is not a ${hit.kind} value this platform can interpret. `
577+
+ `${misread.having} The \`having\` was NOT applied. ${REMEDY[hit.kind]}`,
578+
);
579+
}
492580
throw invalidFilterError(
493581
`aggregate('${object}'): ${column} compares against ${preview(hit.value)} at ${hit.path}, `
494582
+ `which is not a ${hit.kind} value this platform can interpret. Compared with each group as `

0 commit comments

Comments
 (0)