@@ -154,7 +154,7 @@ interface NormalizedCubeFilter {
154154 operator : CubeOperator ;
155155 /**
156156 * The comparands, as authored. Temporal values are put into the field's
157- * storage form at the exits ({@link MemoryAnalyticsService.comparandsFor }),
157+ * storage form at the exits ({@link MemoryAnalyticsService.storageFormFor }),
158158 * never here — that rule needs the resolved field path, which only an exit has.
159159 */
160160 values : unknown [ ] ;
@@ -204,10 +204,65 @@ interface MongoPredicateInput {
204204 * Unicode range and would answer `CAFÉ` for `café`.
205205 */
206206 readonly asciiSubstring : ( value : unknown ) => RegExp ;
207+ /**
208+ * [#20661] One value put into the storage form of the field this entry
209+ * constrains — the SAME conversion `comparands` came out of
210+ * ({@link MemoryAnalyticsService.storageFormFor}), handed over for the one
211+ * value that is not an authored operand: a bound the builder DERIVES.
212+ * See {@link lteUpperBound}.
213+ */
214+ readonly storageForm : ( value : unknown ) => unknown ;
207215}
208216
209217type MongoPredicateBuilder = ( input : MongoPredicateInput ) => Record < string , unknown > ;
210218
219+ /**
220+ * [#20661] What an `lte` bound compiles to, decided ONCE for both exits — the
221+ * mingo `$match` ({@link CUBE_OPERATOR_TO_MONGO_PREDICATE}) and the SQL echo
222+ * ({@link CUBE_OPERATOR_TO_SQL_PREDICATE}) only render it, so the rows a chart
223+ * is drawn from and the statement shown beside it cannot disagree on it.
224+ *
225+ * - `before` — a bare `YYYY-MM-DD` means the WHOLE day (#4042; the SQL twin is
226+ * #3777): the bound is the next day's midnight, exclusive.
227+ * - `unbounded` — the same on `9999-12-31`, which has no next day (#20600):
228+ * every value is inside the bound, so what is left to ask is a value.
229+ * - `through` — anything else keeps instant semantics, inclusive, compared
230+ * against the authored comparand in its storage form.
231+ *
232+ * ## ⛔ The order is the fix: widen the AUTHORED string, then convert the bound
233+ *
234+ * ADR-0053 D-E3: the calendar-day rewrite is a *calendar* operation and runs
235+ * on the bare-day STRING first; only the resulting bound is converted to the
236+ * storage form. Both rows used to ask {@link nextUtcCalendarDay} about
237+ * `comparands[0]`, which is ALREADY in storage form — on a declared `datetime`
238+ * field that is the instant `2026-07-28T00:00:00.000Z`, which the helper
239+ * correctly refuses to widen, so `$lte: '2026-07-28'` compiled an inclusive
240+ * bound at that midnight and dropped the rest of the day, while `find()` on the
241+ * same filter (which widens `val` and converts `nextDay`, `memory-driver.ts`)
242+ * kept it. On an undeclared field the storage form IS the authored string,
243+ * which is why only the declared case was wrong. ⛔ Never teach
244+ * `nextUtcCalendarDay` to widen an instant instead: it refuses one because an
245+ * instant already says where it stops.
246+ *
247+ * `comparand` is the authored value's storage form, passed in rather than
248+ * recomputed: it already exists, and the `through` arm is exactly it.
249+ */
250+ type LteUpperBound =
251+ | { readonly kind : 'before' ; readonly bound : unknown }
252+ | { readonly kind : 'unbounded' }
253+ | { readonly kind : 'through' ; readonly bound : unknown } ;
254+
255+ function lteUpperBound (
256+ authored : unknown ,
257+ comparand : unknown ,
258+ storageForm : ( value : unknown ) => unknown ,
259+ ) : LteUpperBound {
260+ const nextDay = nextUtcCalendarDay ( authored ) ;
261+ if ( isUnboundedAbove ( nextDay ) ) return { kind : 'unbounded' } ;
262+ if ( nextDay != null ) return { kind : 'before' , bound : storageForm ( nextDay ) } ;
263+ return { kind : 'through' , bound : comparand } ;
264+ }
265+
211266/**
212267 * [#5374] How each cube operator becomes a mingo field predicate — the whole
213268 * `{$op: …}` object, not the name of an operator.
@@ -269,10 +324,13 @@ const CUBE_OPERATOR_TO_MONGO_PREDICATE: Readonly<Record<CubeOperator, MongoPredi
269324 // Order-equivalent to `$lte` for plain `YYYY-MM-DD` values.
270325 // [#20600] On the last supported day there is no next day: every value is
271326 // inside the bound, so what `lte` still asks is a value (the `set` row below).
272- lte : ( { comparands } ) => {
273- const nextDay = nextUtcCalendarDay ( comparands [ 0 ] ) ;
274- if ( isUnboundedAbove ( nextDay ) ) return { $ne : null } ;
275- return nextDay != null ? { $lt : nextDay } : { $lte : comparands [ 0 ] } ;
327+ // [#20661] Decided from the AUTHORED value by {@link lteUpperBound}, which
328+ // the SQL twin shares — widening `comparands[0]` read an instant on a
329+ // declared `datetime` field and never widened at all.
330+ lte : ( { raw, comparands, storageForm } ) => {
331+ const upper = lteUpperBound ( raw [ 0 ] , comparands [ 0 ] , storageForm ) ;
332+ if ( upper . kind === 'unbounded' ) return { $ne : null } ;
333+ return upper . kind === 'before' ? { $lt : upper . bound } : { $lte : upper . bound } ;
276334 } ,
277335 // The list operators take the WHOLE list. An empty one is a real predicate —
278336 // `$in: []` selects nothing, `$nin: []` selects everything — and saying so
@@ -326,6 +384,11 @@ interface SqlPredicateInput {
326384 * See {@link globSubstringPattern} for why GLOB and not LIKE.
327385 */
328386 readonly globSubstring : ( value : unknown ) => string ;
387+ /**
388+ * [#20661] The storage-form conversion `comparands` came out of, for a bound
389+ * the builder derives — the twin of {@link MongoPredicateInput.storageForm}.
390+ */
391+ readonly storageForm : ( value : unknown ) => unknown ;
329392}
330393
331394type SqlPredicateBuilder = ( input : SqlPredicateInput ) => string ;
@@ -464,12 +527,15 @@ const CUBE_OPERATOR_TO_SQL_PREDICATE: Readonly<Record<CubeOperator, SqlPredicate
464527 // which is measurable as an echo one row NARROWER than the chart it describes.
465528 // [#20600] …and `IS NOT NULL` on the last supported day, as the mingo row
466529 // above answers `$ne: null` there.
467- lte : ( { column, comparands, literal } ) => {
468- const nextDay = nextUtcCalendarDay ( comparands [ 0 ] ) ;
469- if ( isUnboundedAbove ( nextDay ) ) return `${ column } IS NOT NULL` ;
470- return nextDay != null
471- ? `${ column } < ${ literal ( nextDay ) } `
472- : `${ column } <= ${ literal ( comparands [ 0 ] ) } ` ;
530+ // [#20661] The same {@link lteUpperBound} decision the mingo row renders, so
531+ // on a declared `datetime` field the echo reads `< '2026-07-29T00:00:00.000Z'`
532+ // where it used to read `<= '2026-07-28T00:00:00.000Z'`.
533+ lte : ( { column, raw, comparands, storageForm, literal } ) => {
534+ const upper = lteUpperBound ( raw [ 0 ] , comparands [ 0 ] , storageForm ) ;
535+ if ( upper . kind === 'unbounded' ) return `${ column } IS NOT NULL` ;
536+ return upper . kind === 'before'
537+ ? `${ column } < ${ literal ( upper . bound ) } `
538+ : `${ column } <= ${ literal ( upper . bound ) } ` ;
473539 } ,
474540 // The list operators take the WHOLE list, and an EMPTY one is a real
475541 // predicate on this side too — `$in: []` selects nothing, `$nin: []`
@@ -628,7 +694,7 @@ function numericAggregandExpr(path: string): Record<string, unknown> {
628694 *
629695 * Every other value on this path already renders faithfully, measured rather
630696 * than assumed: a `Date` comparand is canonicalized to an ISO string by
631- * {@link MemoryAnalyticsService.comparandsFor } before it reaches here, and
697+ * {@link MemoryAnalyticsService.storageFormFor } before it reaches here, and
632698 * `toJSON` runs BEFORE a replacer in any case, so dates are unchanged. A
633699 * `BigInt` comparand does throw — but out of mingo's own `Query.compile` during
634700 * EXECUTION, before this dump is ever built, so no replacer here reaches it.
@@ -882,9 +948,11 @@ export class MemoryAnalyticsService implements IAnalyticsService {
882948 // FROM: a boolean reaches mingo as a boolean and `null` as `null`, so a
883949 // predicate over `is_active` or `closed_at` selects the same rows
884950 // `find()` selects instead of none / all of them.
951+ const storageForm = this . storageFormFor ( cube , filter . member ) ;
885952 const predicate = this . mongoPredicateBuilder ( filter . operator ) ( {
886- comparands : this . comparandsFor ( cube , filter . member , filter . values ) ,
953+ comparands : filter . values . map ( storageForm ) ,
887954 raw : filter . values ,
955+ storageForm,
888956 substring : ( value ) => this . driver . filterSubstringPattern ( value ) ,
889957 // [#6520] `$icontains`' fold, from the spec's shared definition rather
890958 // than from the driver's Unicode-folding `filterSubstringPattern`.
@@ -1362,10 +1430,12 @@ export class MemoryAnalyticsService implements IAnalyticsService {
13621430 const normalizedFilters = this . normalizeFilters ( query ) ;
13631431 for ( const filter of normalizedFilters ) {
13641432 const fieldPath = this . resolveFieldPath ( cube , filter . member ) ;
1433+ const storageForm = this . storageFormFor ( cube , filter . member ) ;
13651434 whereClauses . push ( this . sqlPredicateBuilder ( filter . operator ) ( {
13661435 column : fieldPath ,
1367- comparands : this . comparandsFor ( cube , filter . member , filter . values ) ,
1436+ comparands : filter . values . map ( storageForm ) ,
13681437 raw : filter . values ,
1438+ storageForm,
13691439 literal : ( value ) => this . toSqlLiteral ( value ) ,
13701440 globSubstring : ( value ) => this . toSqlLiteral ( globSubstringPattern ( value ) ) ,
13711441 } ) ) ;
@@ -1537,9 +1607,13 @@ export class MemoryAnalyticsService implements IAnalyticsService {
15371607 }
15381608
15391609 /**
1540- * [#5373] The comparands of one lowered entry, in the storage form of the
1541- * field they are compared against — the ONE place either exit converts a
1542- * value, so the two exits cannot drift apart.
1610+ * [#5373] The conversion that puts a value into the storage form of the field
1611+ * one lowered entry is compared against — the ONE place either exit converts
1612+ * a value, so the two exits cannot drift apart. Each exit maps the entry's
1613+ * comparands through it, and hands the same function to its predicate builder
1614+ * for the one value a builder derives rather than receives: the whole-day
1615+ * bound of an `lte` (#20661, {@link lteUpperBound}), which has to be widened
1616+ * from the AUTHORED day before it is converted (ADR-0053 D-E3).
15431617 *
15441618 * The only conversion left is the temporal one (#4047): a `datetime` column
15451619 * holds canonical UTC ISO text, so a `Date` comparand has to become that text
@@ -1552,10 +1626,10 @@ export class MemoryAnalyticsService implements IAnalyticsService {
15521626 * boolean stays a boolean, `null` stays `null`, and a text column's `'100'`
15531627 * stays the string `'100'` instead of becoming the number `100`.
15541628 */
1555- private comparandsFor ( cube : Cube , member : string , values : unknown [ ] ) : unknown [ ] {
1629+ private storageFormFor ( cube : Cube , member : string ) : ( value : unknown ) => unknown {
15561630 const table = this . extractTableName ( cube . sql ) ;
15571631 const fieldPath = this . resolveFieldPath ( cube , member ) ;
1558- return values . map ( v => this . driver . filterComparandStorageForm ( table , fieldPath , v ) ) ;
1632+ return ( value ) => this . driver . filterComparandStorageForm ( table , fieldPath , value ) ;
15591633 }
15601634
15611635 /**
0 commit comments