Skip to content

Commit 1a75e39

Browse files
fix(spec,drivers): a datetime $lte or $between maximum on 9999-12-31 includes the whole last supported day (#20600) (#20643)
Fixes #20600 Clause-②: yes (widening) — three new exports on `@objectstack/spec` (`data`) and `@objectstack/core`: the constant `UNBOUNDED_ABOVE`, its type `UnboundedAbove` and the guard `isUnboundedAbove`; `nextUtcCalendarDay` answers the constant for `9999-12-31`, the one input that used to answer the five-digit `'10000-01-01'`; a `$lte` / `$between` maximum on that day that answered no rows on SQLite now answers the whole day. Nothing any door accepted before is refused, and nothing is removed or renamed. **BREAKING for TypeScript and JavaScript callers of `nextUtcCalendarDay`** (seat ruling B, `5892121354`): its return type gains a member and its answer for one input changes from a string to a symbol, landing as `minor` under the launch-window convention; the changeset carries the banner and the ADR-0087 disposition `not-required (no-migration-prescription)`. Direction from triage `5886142901` (binding, not re-opened): neither `null` nor a five-digit year. Every supported value is at most the last millisecond of `9999-12-31`, so the whole-day bound past the last day is "unbounded above": the helper answers that explicitly, as a distinct value, and the drivers compile no upper bound for it. ## The change - `packages/spec/src/data/calendar-day.ts`: new exports `UNBOUNDED_ABOVE`, its type `UnboundedAbove` (a `symbol` with a structural brand) and the guard `isUnboundedAbove`. `nextUtcCalendarDay` returns `string | UnboundedAbove | null` and answers `UNBOUNDED_ABOVE` for the one real day whose successor has no `YYYY-MM-DD` spelling. The type is structural because `./data` ships as `index.d.mts` and `index.d.ts`: a `unique symbol` was two unrelated types in a program meeting both (the red `Type Check · workspace`), while two copies of the branded alias are one type. It stays a symbol, so a template literal, a relational comparison and a `string` target still refuse it; readers narrow with `isUnboundedAbove` (`===` compares but does not narrow). `@objectstack/core` re-exports all three. - Every reader of the answer handles it explicitly (census below). What each compiles for the last day: - a lone `$lte` (or its AST spelling): no bound, only "the value is not null" (`IS NOT NULL` on SQL and the analytics echo, `$ne: null` on memory and mongo, a value check in the engine's `having`); - a `$between` / `between` maximum, an explicit analytics `dateRange` end: the range keeps its minimum alone; - the type-blind in-process evaluators (`formula`'s RLS `check`, the draft preview): every value that denotes an instant is inside the bound, and any other value is compared as written. - `$gte`, `$gt`, `$lt` and `$eq` on `9999-12-31` do not read the helper and are unchanged (midnight-anchored; `$eq` is that midnight instant). `9999-12-30` and every earlier day compile the same bound as before. - The shared temporal conformance kit (`packages/spec/src/data/temporal-conformance.ts`) gains the row `z_last` (`9999-12-31T10:00:00.000Z`, `native` writer form) and five last-day cases (`$lte` on `datetime` and on `date`, two `$between` maxima with their analytics `dateRange` spellings, and the `9999-12-30` control), so every backend the kit drives, the `Temporal Conformance (live PG + MySQL)` job included, is held to this answer. Three existing `$gte` / `$gt` cases now also expect `z_last`. ### Reader census (the answer of `nextUtcCalendarDay`, measured with `git grep` at `c1d8051e0a`) | reader | site | today, for the last day | now | |:--|:--|:--|:--| | `driver-sql` `calendarDayExclusiveUpperBound` (+ `calendarDayUpperBoundRewrite`, `calendarDayBetweenRewrite`, the `where` emitter) | `sql-driver.ts` | bound `10000-01-01…`; SQLite: no rows | `IS NOT NULL` / the minimum only | | `driver-turso` remote lowering (inherits the rewrite) | `turso-driver.ts` `toRemoteUpperBound` | same bound, sent to the transport | `$null: false` / `$gte` only | | `driver-memory` `$lte`, `$between` and their AST spellings | `memory-driver.ts` (4) | `$lt '10000-01-01'`: no rows | `$ne: null` / `$gte` only | | `driver-memory` cube `lte` (mingo and SQL echo), `dateRange` window | `memory-analytics.ts` (3) | same | `$ne: null` / `IS NOT NULL` / start only | | `driver-mongodb` `$lte`, `$between` | `mongodb-filter.ts` (2) | `$lt` the stored form of `10000-01-01` | `$ne: null` / `$gte` only | | `formula` `lteBound` (RLS `check`) | `matches-filter.ts` | a `'2026-…'` value sorts above `'10000-01-01'`: write denied | instant: admitted; else as written | | `objectql` `wholeDayUpperBound` (`having`, per-aggregation `filter`) | `having-filter.ts` | same text bound | a value / `min` only | | `service-analytics` preview `lteBound`, `dateRange` | `preview-evaluator.ts` (2) | same text bound | instant: in; window: start only | | `service-analytics` native SQL `lte`, `dateRange` | `native-sql-strategy.ts` (2) | same bound | `IS NOT NULL` / start only | | `service-analytics` ObjectQL echo `dateRange` | `objectql-strategy.ts` | echoed `10000-01-01` | start only (the driver runs no upper bound) | | `spec` `utcInstantMs` validity test | `calendar-day.ts` | reads `!== null` | unchanged, pinned | | test readers | `core` `analytics-date-range.test.ts`, `spec` `date-range-presets.test.ts` | read the answer as a string | assert it is a string | The seat's list also named `core` `temporal-storage-form.ts` / `analytics-date-range.ts`, `spec` `date-range-presets.ts` / `temporal-conformance.ts` and `driver-mongodb` `mongodb-temporal.ts`: they mention the helper in comments only and read no answer. The claimed `service-analytics` files `analytics-service.ts`, `cube-registry.ts`, `dataset-executor.ts`, `dataset-compiler.ts` do not read it and are untouched. ### Shape, measured (hypothesis 2) Round 1 kept `unique symbol` and measured 3 of 16 reader sites refused and 13 silent under the widened type. The at-tier review found the type nominal per declaration file: `Type Check · workspace` went red in `@objectstack/dogfood` (TS2367 at `turso-driver.ts` 2640, TS2339 at 2641), reproduced locally at `e197faa978` with CI's own command. Round 2 types the sentinel `symbol` plus a structural brand and adds the shared guard: the same workspace typecheck is green at `49a2585988` (135 of 135 tasks, dogfood included), and a scratch consumer compiled against the built `dist/data/index.d.ts` and `index.d.mts` together compares and narrows across the two files while a template literal, a relational comparison and a `string` target still refuse the member (TS2731, TS2469, TS2322). A string sentinel is excluded by ruling; a tagged object compiles silently in a template literal. ## Verification Premise, on the base `c1d8051e0a`, through `POST /api/v1/data/:object/query` (the new `packages/rest/src/data-query-calendar-day-last-day.test.ts`, process in America/New_York, a private PostgreSQL 16.13 with server zone Asia/Shanghai): | `where opened_at` | SQLite, base | PostgreSQL 16, base | head, both | |:--|:--|:--|:--| | `$lte '9999-12-31'` | none | `c26`, `prev`, `open`, `mid`, `last` | the same five | | `$between ['2026-01-01', '9999-12-31']` | none | the same five | the same five | | `$between ['9999-12-31', '9999-12-31']` | none | `open`, `mid`, `last` | `open`, `mid`, `last` | | `$not` of `$lte '9999-12-31'` | all six, `none` included | `none` | `none` | | `$lte '9999-12-30'` (control) | `c26`, `prev` | `c26`, `prev` | the same | | `$gte` / `$gt` / `$lt` / `$eq` `'9999-12-31'` | unchanged | unchanged | unchanged | Rows: `c26` `2026-07-15T14:00Z` (the card's control), `prev` `9999-12-30T10:00Z`, `open` `9999-12-31T00:00Z`, `mid` `9999-12-31T10:00Z` (the card's row), `last` `9999-12-31T23:59:59.999Z`, `none` no value. Reverse verification (fix committed first at `f4d15dd506`, mutation through `scripts/ablation-replace.mjs` under a `trap` restore to the `HEAD` blob): the helper's last line was replaced by `return next || 'ABLATION_20600';`, so it answers the successor spelling again (the five-digit `'10000-01-01'` for the last day; the marker never fires and only proves the mutation reached `dist`); anchor 1 to 0 and replacement 0 to 1 on disk; `pnpm --filter @objectstack/spec build`; `scripts/ablation-dist-preflight.mjs` found the mutation in `dist`. Mutated leg: `spec` calendar-day 2 failed; `driver-sql` 9 failed (the SQLite and legacy-storage cells; the PostgreSQL cells stayed green, as on the base); `driver-memory` 11; `driver-mongodb` 3; `formula` 10; `objectql` 3; `service-analytics` 17; `driver-turso` 12; `rest` 1 (the SQLite cell). Restore: blob equals `HEAD`, `git diff HEAD` empty, spec rebuilt, the preflight found the mutation in no built file, and the same battery went green (16, 135 plus 1 MySQL skip, 83, 50, 84, 89, 122, 150, 4). Tests and gates at `49a2585988` (origin/main `f4ce10c89d` merged): the whole-workspace typecheck of the `Type Check · workspace` job, 135 of 135 tasks; the reader battery under America/New_York on SQLite (spec 28, core 73, driver-sql 91, driver-memory 83, driver-mongodb 84, formula 84, objectql 89, service-analytics 122, driver-turso 150, driver-sqlite-wasm 71, rest 4) all green; `check:generated` all 15 up to date after regenerating `api-surface` and `export-origins`; 94 derived gates all exit 0 and reconciled with `--ran` (94 run, 0 NOT MEASURED); narrowed lint 23 files, 0 errors, 0 warnings. The round-1 full suites (at `f7a61d512e` and `e197faa978`) and the live PostgreSQL cells stand from round 1; round 2 changes the readers' test from `=== UNBOUNDED_ABOVE` to `isUnboundedAbove(…)`, whose body is that comparison. ## Acceptance notes - **Hypothesis 3, one part falsified.** The dispatch expected `$eq '9999-12-31'` to "still mean that whole day". No reader applies the whole-day reading to `$eq`: on every backend a bare day under `$eq` on a `datetime` is that day's midnight instant, and none of them reads `nextUtcCalendarDay` for it. It is unchanged here and pinned as such (`$eq '9999-12-31'` answers `open`, the midnight row, before and after). - **Type-blind emitters on a non-temporal field.** The memory and mongo drivers, the memory cube filter and the native-SQL `lte` widen without knowing the column type, as their bounded rule always has (`$lte day` becomes "before the next day" on any column). On the last day they compile "has a value" the same way, so a bare-day `$lte '9999-12-31'` on a text or number field answers every non-null row there. `formula`'s `check` and the draft preview, which see the value, admit an instant and compare anything else as written. The SQL drivers and the engine's `having` scope the rule to `datetime` columns and are exact. - **Remote Turso and a contested `$null`.** The remote lowering assigns lowered keys into one operator map, so the new `$null: false` shares a key with an author's own `$null` beside a last-day `$lte`, as its existing `$lte` to `$lt` and `$between` to `$gte` lowerings already share theirs (the memory and mongo translators assemble such writes into `$and` branches; the remote path does not). Read from the code, not reproduced; noted, not filed. - **Engine `having` / per-aggregation `filter`, `$between` on a missing value.** A `null` aggregate value passes a `$between` there, whatever its bounds (neither `ordered(null, …)` comparison is true). Unchanged by this PR; read from the code, not reproduced. - **MySQL and a real mongod were not run locally.** No `mysqld` in this container; the `Temporal Conformance (live PG + MySQL)` job runs the new kit rows on MySQL 8.0. The real-mongod half of the mongo conformance matrix is opt-in (`OS_TEST_MONGODB_MEMORY_SERVER_ENABLED`) and was not run; the mongo translator is pinned server-free in `mongodb-filter.test.ts`. - **The widened return type is compile-visible.** A TypeScript caller that uses `nextUtcCalendarDay`'s answer as a string stops compiling (3 of the 16 in-repo sites did). Declared `yes (widening)`, not `(narrowing)`: no input the platform accepted is refused, nothing is removed or renamed. The changeset states the one-line handling for such a caller. - **Out of scope, reported to the seat: the memory cube filter never widens a declared `datetime`.** `MemoryAnalyticsService` converts a comparand to the field's storage form before its `lte` row asks `nextUtcCalendarDay`, so on a DECLARED `datetime` the helper sees an instant and declines, on every day. Measured on the built `@objectstack/driver-memory` at this head, rows at `2026-07-28T10:00Z` and `2026-07-27T10:00Z`, `where { created_at: { $lte: '2026-07-28' } }`: undeclared field, both rows (the SQL echo compiles the half-open bound at `'2026-07-29'`); declared `datetime`, only the 07-27 row (the echo compiles an inclusive bound at `'2026-07-28T00:00:00.000Z'`), while `find()` answers both. Not the last-day defect, so not fixed here. → filed by the seat as #20661. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 05cb2bc commit 1a75e39

26 files changed

Lines changed: 903 additions & 66 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/core': minor
4+
'@objectstack/driver-sql': patch
5+
'@objectstack/driver-turso': patch
6+
'@objectstack/driver-memory': patch
7+
'@objectstack/driver-mongodb': patch
8+
'@objectstack/formula': patch
9+
'@objectstack/objectql': patch
10+
'@objectstack/service-analytics': patch
11+
---
12+
13+
fix(spec,drivers): a `datetime` filter `$lte '9999-12-31'`, or a `$between` whose maximum is that day, includes the whole last supported day on every backend (#20600)
14+
15+
Clause-②: yes (widening) — three new exports on `@objectstack/spec` (`data`) and `@objectstack/core`: the constant `UNBOUNDED_ABOVE`, its type `UnboundedAbove` and the guard `isUnboundedAbove`; `nextUtcCalendarDay` answers the constant for one input that used to answer a string. Nothing any door accepted before is refused, and nothing is removed or renamed.
16+
17+
**BREAKING for TypeScript and JavaScript callers of `nextUtcCalendarDay`** (`@objectstack/spec/data`, re-exported by `@objectstack/core`): its return type gains a member and its answer for one input changes from a string to a symbol, landing in the launch window as `minor` (the lockstep convention: the bump level is not the carrier, this banner and the disposition below are). No filter an author writes and no stored row changes meaning except that a whole-day upper bound on `9999-12-31` now includes that day.
18+
19+
`9999-12-31` is the last day of the supported years (0001..9999). A bare-day upper bound on a `datetime` field — `$lte`, a `$between` maximum, an analytics `dateRange` end — means that whole day, and is compiled as "before the next day's midnight". That day has no next day with a `YYYY-MM-DD` spelling: `nextUtcCalendarDay('9999-12-31')` answered the five-digit `'10000-01-01'`, which sorts below `'2026-…'` as text. So on SQLite, where a `datetime` column is ISO text, `$lte '9999-12-31'` and `$between ['2026-01-01', '9999-12-31']` answered no rows; PostgreSQL parsed the bound as an instant and answered them. The memory and mongo drivers, the analytics strategies and the draft preview built their bound from the same answer, and `formula`'s RLS `check` evaluator compared a `'2026-…'` value against it and denied the write.
20+
21+
Every supported value is at most the last millisecond of `9999-12-31`, so that day's whole-day bound bounds nothing. `nextUtcCalendarDay('9999-12-31')` now answers `UNBOUNDED_ABOVE`, a symbol that is neither `null` ("not a calendar day", which would compile the day's midnight and miss the rest of it) nor a string, and every backend compiles no upper bound for it:
22+
23+
- `$lte` / `<=` on that day asks only that the value is not null: `IS NOT NULL` on the SQL drivers and the analytics echo, `$ne: null` on the memory and mongo drivers.
24+
- A `$between` / `between` whose maximum is that day, and an explicit analytics `dateRange` ending on it, keep only their minimum.
25+
- The type-blind `formula` `check` evaluator and the draft preview admit every value that denotes an instant, and compare any other value as written.
26+
- `$gte`, `$gt`, `$lt` and `$eq` on that day are unchanged: they anchor to its midnight, as on every other day. `9999-12-30` and every earlier day compile the same bound as before.
27+
28+
Measured through `POST /api/v1/data/:object/query`, rows at `2026-07-15T14:00Z`, `9999-12-30T10:00Z`, `9999-12-31T00:00Z`, `T10:00Z` and `T23:59:59.999Z`: on SQLite, `$lte '9999-12-31'` and `$between ['2026-01-01', '9999-12-31']` answered none of them and now answer all five; `$between ['9999-12-31', '9999-12-31']` answered none and now answers the three on that day. PostgreSQL 16 answers the same before and after. `$lte '9999-12-30'` answers the first two rows on both, before and after.
29+
30+
**If your code stops compiling.** `nextUtcCalendarDay` now returns `string | UnboundedAbove | null`, where `UnboundedAbove` is a `symbol` with a structural brand. TypeScript refuses that member in a template literal (TS2731), a relational comparison (TS2469) and a `string` parameter (TS2345), so code that used the answer as a day string no longer compiles until it handles the last day. Test the answer with `isUnboundedAbove(answer)` (or `typeof answer === 'symbol'`) first: on its false branch the answer is `string | null` as before, and on its true branch there is no upper bound to compile. `answer === UNBOUNDED_ABOVE` compares correctly but does not narrow, because the branded type is not a unit type. The type is structural on purpose: `@objectstack/spec` ships `./data` as `index.d.mts` and `index.d.ts`, and a `unique symbol` would be two unrelated types in a program that meets both.
31+
32+
**If your JavaScript code handled the answer as text.** For `'9999-12-31'` it is now a registered symbol (`Symbol.for('objectstack.calendarDay.unboundedAbove')`), not `'10000-01-01'`: a template literal or a relational comparison on it throws a `TypeError`, and better-sqlite3 and `pg` refuse to bind it. Every other input answers exactly as before.
33+
34+
The shared temporal conformance kit (`TEMPORAL_ROWS` / `TEMPORAL_CASES` in `@objectstack/spec/data`) gains the row `z_last` (`9999-12-31T10:00:00.000Z`) and five last-day cases, so every backend it drives is held to this answer; three existing `$gte` / `$gt` cases now also expect `z_last`.
35+
36+
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author writes moves — no spec key, no stored row and no accept set changes, so `objectstack migrate meta` has nothing to reach — and what moves is one published function's return type and its answer for one input, whose channel is the caller's compiler and the banner above. -->

‎packages/core/src/utils/analytics-date-range.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,8 +316,10 @@ describe('#17341 — the END of every calendar window is one CALENDAR day after
316316
const where = `${preset} @ ${tz ?? 'UTC'} @ ${now.toISOString()}`;
317317
const lastDay = String(resolveFilterToken(prescribedEnd, { now, timezone: tz }));
318318
const stopsBefore = nextUtcCalendarDay(lastDay);
319-
expect(stopsBefore, where).not.toBeNull();
320-
const expected = new Date(zonedDateStartToUtcMs(stopsBefore!, tz)).toISOString();
319+
// A day: never `null`, and never `UNBOUNDED_ABOVE` — the
320+
// sweep's instants sit nowhere near 9999-12-31 (#20600).
321+
expect(typeof stopsBefore, where).toBe('string');
322+
const expected = new Date(zonedDateStartToUtcMs(stopsBefore as string, tz)).toISOString();
321323
expect(resolveAnalyticsDateRangePreset(preset, { now, timezone: tz }).end, where)
322324
.toBe(expected);
323325
}

‎packages/core/src/utils/datetime.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,12 @@ export function zonedWallClockToUtcMs(parts: WallClockParts, tz?: string): numbe
192192
*
193193
* Re-exported here so the published `@objectstack/core` surface is unchanged
194194
* for the drivers and analytics strategies that already import it from here.
195+
* [#20600] `UNBOUNDED_ABOVE`, its type `UnboundedAbove` and the guard
196+
* `isUnboundedAbove` travel with the helper: the constant is the helper's answer
197+
* for `9999-12-31`, and every caller of one narrows it with the guard.
195198
*/
196-
export { nextUtcCalendarDay, utcInstantMs } from '@objectstack/spec/data';
199+
export { nextUtcCalendarDay, utcInstantMs, UNBOUNDED_ABOVE, isUnboundedAbove } from '@objectstack/spec/data';
200+
export type { UnboundedAbove } from '@objectstack/spec/data';
197201

198202
/**
199203
* Granularity of a canonical date-bucket key. Mirrors `@objectstack/spec`'s

‎packages/drivers/driver-memory/src/memory-analytics.ts‎

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
Logger,
1212
createLogger,
1313
nextUtcCalendarDay,
14+
isUnboundedAbove,
1415
// [#16322] The ONE lowering of the closed `dateRange` preset vocabulary and
1516
// the ONE refusal for a string outside it, shared with the SQL analytics
1617
// path so the two backends cannot answer one input differently again.
@@ -266,8 +267,11 @@ const CUBE_OPERATOR_TO_MONGO_PREDICATE: Readonly<Record<CubeOperator, MongoPredi
266267
// A bare-day `lte` bound means "through that whole day" (#4042; the SQL twin
267268
// is #3777): compile half-open so timestamp values on the final day stay in.
268269
// Order-equivalent to `$lte` for plain `YYYY-MM-DD` values.
270+
// [#20600] On the last supported day there is no next day: every value is
271+
// inside the bound, so what `lte` still asks is a value (the `set` row below).
269272
lte: ({ comparands }) => {
270273
const nextDay = nextUtcCalendarDay(comparands[0]);
274+
if (isUnboundedAbove(nextDay)) return { $ne: null };
271275
return nextDay != null ? { $lt: nextDay } : { $lte: comparands[0] };
272276
},
273277
// The list operators take the WHOLE list. An empty one is a real predicate —
@@ -458,8 +462,11 @@ const CUBE_OPERATOR_TO_SQL_PREDICATE: Readonly<Record<CubeOperator, SqlPredicate
458462
// Half-open on a bare-day bound, exactly as the mingo row above is (#4042; the
459463
// SQL twin is #3777). `<= '2026-01-02'` drops that day's timestamped rows,
460464
// which is measurable as an echo one row NARROWER than the chart it describes.
465+
// [#20600] …and `IS NOT NULL` on the last supported day, as the mingo row
466+
// above answers `$ne: null` there.
461467
lte: ({ column, comparands, literal }) => {
462468
const nextDay = nextUtcCalendarDay(comparands[0]);
469+
if (isUnboundedAbove(nextDay)) return `${column} IS NOT NULL`;
463470
return nextDay != null
464471
? `${column} < ${literal(nextDay)}`
465472
: `${column} <= ${literal(comparands[0])}`;
@@ -991,17 +998,27 @@ export class MemoryAnalyticsService implements IAnalyticsService {
991998
//
992999
// Anything else -- a full timestamp the CALLER wrote -- keeps
9931000
// instant semantics and stays INCLUSIVE, byte for byte as before.
994-
const widenedDay = resolved.endExclusive ? null : nextUtcCalendarDay(end);
1001+
//
1002+
// [#20600] A caller's bare end on the last supported day has no next
1003+
// day to stop before: every value is inside it, so the window keeps
1004+
// its start alone, in both spellings.
1005+
const widened = resolved.endExclusive ? null : nextUtcCalendarDay(end);
1006+
const unbounded = isUnboundedAbove(widened);
1007+
const widenedDay = isUnboundedAbove(widened) ? null : widened;
9951008
const upperString = resolved.endExclusive ? end : widenedDay;
9961009
const upperDate = widenedDay != null
9971010
? new Date(`${widenedDay}T00:00:00.000Z`)
9981011
: (resolved.endExclusive ? new Date(end) : null);
999-
const stringBounds = upperString != null
1000-
? { $gte: start, $lt: upperString }
1001-
: { $gte: start, $lte: end };
1002-
const dateBounds = upperDate != null
1003-
? { $gte: new Date(start), $lt: upperDate }
1004-
: { $gte: new Date(start), $lte: new Date(end) };
1012+
const stringBounds = unbounded
1013+
? { $gte: start }
1014+
: upperString != null
1015+
? { $gte: start, $lt: upperString }
1016+
: { $gte: start, $lte: end };
1017+
const dateBounds = unbounded
1018+
? { $gte: new Date(start) }
1019+
: upperDate != null
1020+
? { $gte: new Date(start), $lt: upperDate }
1021+
: { $gte: new Date(start), $lte: new Date(end) };
10051022
pipeline.push({
10061023
$match: {
10071024
$or: [

‎packages/drivers/driver-memory/src/memory-driver-calendar-day-upper-bound.test.ts‎

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
*/
1818

1919
import { describe, it, expect, beforeEach } from 'vitest';
20-
import { parseFilterAST } from '@objectstack/spec/data';
20+
import { parseFilterAST, type FilterCondition } from '@objectstack/spec/data';
2121
import { InMemoryDriver } from './memory-driver.js';
2222
import { MemoryAnalyticsService } from './memory-analytics.js';
2323
import type { Cube } from '@objectstack/spec/data';
@@ -153,3 +153,103 @@ describe('MemoryAnalyticsService — dateRange window (#4042)', () => {
153153
expect(result.rows[0]?.count).toBe(4);
154154
});
155155
});
156+
157+
/**
158+
* [#20600] `9999-12-31`, the last supported day, has no next day: every
159+
* supported value is inside its whole-day bound, so the whole-day widening
160+
* compiles NO upper bound — `$lte` / `<=` ask only for a value (`$ne: null`), a
161+
* `$between` / `between` / `dateRange` keeps its minimum. The spec's helper
162+
* used to answer the five-digit `'10000-01-01'`, and every ISO-string row
163+
* sorts above it, so each of those answered no rows. `9999-12-30` is the
164+
* control: an ordinary bound.
165+
*/
166+
describe('[#20600] InMemoryDriver — a bare-day upper bound on the last supported day', () => {
167+
let driver: InMemoryDriver;
168+
169+
beforeEach(async () => {
170+
driver = new InMemoryDriver({
171+
initialData: {
172+
task: [
173+
{ id: 'c26', title: 'c26', created_at: '2026-07-15T14:00:00.000Z' },
174+
{ id: 'prev', title: 'prev', created_at: '9999-12-30T10:00:00.000Z' },
175+
{ id: 'open', title: 'open', created_at: '9999-12-31T00:00:00.000Z' },
176+
{ id: 'mid', title: 'mid', created_at: '9999-12-31T10:00:00.000Z' },
177+
{ id: 'last', title: 'last', created_at: '9999-12-31T23:59:59.999Z' },
178+
{ id: 'none', title: 'none', created_at: null },
179+
],
180+
},
181+
});
182+
await driver.connect();
183+
});
184+
185+
/** `where` · the ids it answers, sorted. */
186+
const CASES: ReadonlyArray<readonly [string, () => FilterCondition | undefined, readonly string[]]> = [
187+
["$lte '9999-12-31'", () => ({ created_at: { $lte: '9999-12-31' } }), ['c26', 'last', 'mid', 'open', 'prev']],
188+
["$between ['2026-01-01', '9999-12-31']", () => ({ created_at: { $between: ['2026-01-01', '9999-12-31'] } }), ['c26', 'last', 'mid', 'open', 'prev']],
189+
["$between ['9999-12-31', '9999-12-31']", () => ({ created_at: { $between: ['9999-12-31', '9999-12-31'] } }), ['last', 'mid', 'open']],
190+
["$not $lte '9999-12-31'", () => ({ $not: { created_at: { $lte: '9999-12-31' } } }), ['none']],
191+
// The lowered `$ne` shares a key an author can write: both constraints survive (#13524).
192+
["$lte '9999-12-31' beside an author's $ne", () => ({ created_at: { $lte: '9999-12-31', $ne: '9999-12-31T10:00:00.000Z' } }), ['c26', 'last', 'open', 'prev']],
193+
["the AST spelling <= '9999-12-31'", () => parseFilterAST([['created_at', '<=', '9999-12-31']]), ['c26', 'last', 'mid', 'open', 'prev']],
194+
["the AST spelling between max '9999-12-31'", () => parseFilterAST([['created_at', 'between', ['9999-12-31', '9999-12-31']]]), ['last', 'mid', 'open']],
195+
["$lte '9999-12-30' (control)", () => ({ created_at: { $lte: '9999-12-30' } }), ['c26', 'prev']],
196+
["$between ['2026-01-01', '9999-12-30'] (control)", () => ({ created_at: { $between: ['2026-01-01', '9999-12-30'] } }), ['c26', 'prev']],
197+
["$gte '9999-12-31' (unchanged)", () => ({ created_at: { $gte: '9999-12-31' } }), ['last', 'mid', 'open']],
198+
["$lt '9999-12-31' (unchanged)", () => ({ created_at: { $lt: '9999-12-31' } }), ['c26', 'prev']],
199+
];
200+
201+
it('compiles no upper bound on 9999-12-31; 9999-12-30 is a bound; the lower-bound operators do not move', async () => {
202+
const got: Record<string, string[]> = {};
203+
for (const [name, where] of CASES) got[name] = ids(await driver.find('task', { where: where() }));
204+
expect(got).toEqual(Object.fromEntries(CASES.map(([name, , want]) => [name, want])));
205+
});
206+
207+
it('the analytics face: a dateRange ending 9999-12-31 keeps its start alone, for BOTH stored forms', async () => {
208+
const analytics = new InMemoryDriver({
209+
initialData: {
210+
task: [
211+
{ id: 's_before', created_at: '2026-06-30T23:00:00.000Z' },
212+
{ id: 's_in', created_at: '2026-07-10T08:00:00.000Z' },
213+
{ id: 's_last_day', created_at: '9999-12-31T21:40:00.000Z' },
214+
{ id: 'd_in', created_at: new Date('2026-07-10T12:00:00Z') },
215+
{ id: 'd_last_day', created_at: new Date('9999-12-31T09:15:00Z') },
216+
{ id: 'd_before', created_at: new Date('2026-04-19T10:00:00Z') },
217+
],
218+
},
219+
});
220+
await analytics.connect();
221+
const service = new MemoryAnalyticsService({
222+
driver: analytics,
223+
cubes: [{
224+
name: 'tasks', title: 'Tasks', sql: 'task',
225+
measures: { count: { label: 'Count', type: 'count', sql: 'id' } },
226+
dimensions: { created_at: { label: 'Created', type: 'time', sql: 'created_at' } },
227+
} as unknown as Cube],
228+
});
229+
const count = async (dateRange: [string, string]) =>
230+
(await service.query({ cube: 'tasks', measures: ['count'], timeDimensions: [{ dimension: 'created_at', dateRange }] } as any)).rows[0]?.count;
231+
232+
expect(await count(['2026-07-01', '9999-12-31'])).toBe(4); // s_in, s_last_day, d_in, d_last_day
233+
expect(await count(['2026-07-01', '9999-12-30'])).toBe(2); // the control: the last day is out
234+
});
235+
236+
it('the analytics face: a cube-filter lte on 9999-12-31 asks only for a value, in the pipeline and the echo', async () => {
237+
const service = new MemoryAnalyticsService({
238+
driver,
239+
cubes: [{
240+
name: 'tasks', title: 'Tasks', sql: 'task',
241+
measures: { count: { label: 'Count', type: 'count', sql: 'id' } },
242+
dimensions: {
243+
id: { label: 'Id', type: 'string', sql: 'id' },
244+
created_at: { label: 'Created', type: 'time', sql: 'created_at' },
245+
},
246+
} as unknown as Cube],
247+
});
248+
const q = (day: string) => ({ cube: 'tasks', measures: ['count'], dimensions: ['id'], where: { created_at: { $lte: day } } } as any);
249+
250+
expect(ids((await service.query(q('9999-12-31'))).rows)).toEqual(['c26', 'last', 'mid', 'open', 'prev']);
251+
expect(ids((await service.query(q('9999-12-30'))).rows)).toEqual(['c26', 'prev']);
252+
expect((await service.generateSql(q('9999-12-31'))).sql).toContain('created_at IS NOT NULL');
253+
expect((await service.generateSql(q('9999-12-30'))).sql).toContain("created_at < '9999-12-31'");
254+
});
255+
});

0 commit comments

Comments
 (0)