Skip to content

Commit 0af50a3

Browse files
os-zhuangclaude
andauthored
fix(driver-sql,service-analytics): bare-day upper bounds cover the whole day on Field.datetime (#3777) (#4041)
* fix(driver-sql,service-analytics): bare-day upper bounds cover the whole day on Field.datetime (#3777) A bare YYYY-MM-DD comparand anchors to midnight UTC — right for a lower bound, silently wrong for an upper one: the dashboard date-range filter compiles { $gte: from, $lte: to } with bare-day bounds, so on a datetime column every row created after 00:00 of the final day vanished. The default configuration hit it: the filter's default field is created_at (a system-injected Field.datetime) and 7 of 13 presets end "today". The translation is operator-sensitive and half-open ([gte, lt) — the drill ranges' shape, never an inclusive 23:59:59.999), applied at the emitters that own an operator, while temporalFilterValue stays form-only: - SqlDriver (SqliteWasmDriver inherits): $lte/<= bare-day on datetime -> < next-day-midnight in storage form; $between [min, max] with a bare-day max decomposes to >= min AND < next-day(max). Covers the plain and the legacy-repair (mixed-storage) column paths and both where spellings. - NativeSQLStrategy: dateRange windows and lte filters bind < next-day. - ObjectQLStrategy: execution keeps bare bounds (the driver rewrite is the single authority); /analytics/sql renders half-open so the echoed SQL reproduces execution. - Preview evaluator: the same rule in memory, replacing the '~' hack. One shared primitive: nextUtcCalendarDay (@objectstack/core) — rejects instants, Dates and impossible days rather than inventing a bound. Unchanged per the #3777 semantics table: date/time columns, full-ISO/Date comparands, $gte/$gt/$lt. Tests: the issue repro verbatim as the acceptance gate (row results), operator x field-type x storage-form matrix incl. un-backfilled mixed storage, dialect physical forms, boundary rollovers, a wasm inheritance pin, strategy SQL shapes, preview parity. ADR-0053 gains the D-D addendum; D-A3's matrix gains the bound-semantics axis. Closes #3777 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EkL3RGJrzjLEGURsLxfS2f * docs(objectql): state the calendar-day bound semantics where date filters are documented (#3777) The Date Filters section demos the exact shape whose upper-bound semantics #3777 fixed, without saying what the bounds mean. Now it does: bare-day lower bound = start of day, bare-day upper bound = the whole day (compiled half-open on datetime columns), full ISO timestamp = exact instant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EkL3RGJrzjLEGURsLxfS2f --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4580597 commit 0af50a3

16 files changed

Lines changed: 784 additions & 34 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/core": minor
3+
"@objectstack/driver-sql": patch
4+
"@objectstack/driver-sqlite-wasm": patch
5+
"@objectstack/service-analytics": patch
6+
---
7+
8+
fix(driver-sql,service-analytics): a bare-day upper bound covers the whole day on `Field.datetime` (#3777)
9+
10+
A bare `YYYY-MM-DD` comparand anchors to midnight UTC. That is right for a
11+
lower bound and was silently wrong for an upper one: the dashboard date-range
12+
filter compiles `{ $gte: from, $lte: to }` with bare-day bounds, so on a
13+
`datetime` column every row created after 00:00 of the `to` day vanished from
14+
the result — no error, the chart renders, the numbers are just smaller. The
15+
default configuration hit it: the filter's default field is `created_at`
16+
(a system-injected `Field.datetime`) and 7 of the 13 presets end "today".
17+
18+
The translation is operator-sensitive and half-open, applied at every
19+
comparison emitter:
20+
21+
- `SqlDriver` (and `SqliteWasmDriver` by inheritance): `$lte`/`<=` with a
22+
bare-day comparand on a `datetime` column compiles to `< next-day-midnight`
23+
in the column's storage form; `$between [min, max]` with a bare-day max
24+
decomposes to `>= min AND < next-day(max)`. Both the plain and the
25+
legacy-repair (mixed-storage) column paths, both `where` spellings.
26+
- `NativeSQLStrategy`: `dateRange` windows and `lte` filters bind `< next-day`
27+
instead of an inclusive `BETWEEN`/`<=` when the bound is a bare day.
28+
- The `/analytics/sql` rendering and the dataset preview evaluator apply the
29+
same rule, so the echoed SQL and drafted numbers reproduce execution.
30+
31+
`@objectstack/core` gains the shared primitive `nextUtcCalendarDay(value)`:
32+
the next calendar day of a valid bare `YYYY-MM-DD` (else `null` — instants,
33+
`Date`s and impossible days are never widened).
34+
35+
Unchanged on purpose, per the semantics table on #3777: `date`/`time` columns
36+
(`<= day` is already whole-day-correct there), full-ISO/`Date` comparands
37+
(instant semantics), and `$gte`/`$gt`/`$lt` (midnight anchoring is correct for
38+
those). No authored metadata changes: a dashboard's existing
39+
`{ $gte, $lte }` window now simply includes its final day.

content/docs/protocol/objectql/query-syntax.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,13 @@ where: { due_date: { $lt: '2024-06-01' } }
306306
where: { created_at: { $gte: '2024-01-01' } }
307307
```
308308

309+
A bare `YYYY-MM-DD` bound is a **calendar day**. As a lower bound (`$gte`) it
310+
means the start of that day (midnight UTC); as an upper bound (`$lte`, or the
311+
max of a `$between`) it covers the **whole** day — on a `datetime` column the
312+
driver compiles it half-open (`< next day`), so the `$between` above includes
313+
everything that happened on Dec 31. A full ISO timestamp keeps exact-instant
314+
semantics on every operator.
315+
309316
### Null Checks
310317

311318
{/* os:check */}

docs/adr/0053-date-and-datetime-semantics.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,3 +658,62 @@ host's zone) is not recoverable. Regression cover:
658658
`sql-driver-time-canonical-storage.test.ts`, `sql-driver-time-of-day.test.ts`
659659
(SQLite), `sql-driver-time-live-dialects.test.ts` (live PG + MySQL, in the CI
660660
temporal-conformance job's non-UTC matrix).
661+
662+
---
663+
664+
## Addendum (2026-07-30) — a bare-day UPPER bound means the whole day (#3777)
665+
666+
> **Status:** landed. Extends Phase 1's calendar-day semantics with the
667+
> operator-sensitive half the original decision left implicit — and the default
668+
> dashboard configuration hit it: the date-range filter's default field is
669+
> `created_at`, a system-injected `Field.datetime`, and 7 of 13 presets end
670+
> "today", so `{ $lte: <today> }` anchored to midnight silently dropped every
671+
> row created after 00:00 of the final day.
672+
673+
### D-D1 — Operator-sensitive translation lives at the comparison EMITTERS
674+
675+
`YYYY-MM-DD` anchors to midnight UTC (D-B1). That instant is the correct
676+
comparand for `$gte`/`$gt`/`$lt` — and the wrong one for `$lte`, whose author
677+
means "through the whole of that day". The translation is therefore a property
678+
of the *comparison*, not of the value: `temporalFilterValue` stays
679+
operator-blind (form only), and each emitter that owns an operator compiles a
680+
bare-day upper bound half-open:
681+
682+
- **`SqlDriver` filter compiler** (`calendarDayUpperBoundRewrite` /
683+
`calendarDayBetweenRewrite`): `$lte`/`<=``< next-day-midnight` in storage
684+
form; `$between [min, max]` with a bare-day max decomposes to
685+
`>= min AND < next-day(max)`. Applies on both the plain and the
686+
CASE-normalised (D-B2) column paths, and to the Mongo-style and array
687+
`where` spellings. `driver-sqlite-wasm` inherits.
688+
- **`NativeSQLStrategy`** windows and `lte` filters bind `< next-day` instead
689+
of `BETWEEN`/`<=` when the bound is a bare day.
690+
- **`ObjectQLStrategy`** leaves its lowered `{$gte, $lte}` bounds bare — the
691+
driver rewrite is the single execution-path authority — and renders
692+
`/analytics/sql` half-open so the echoed SQL reproduces execution.
693+
- **The dataset preview evaluator** applies the same rule in memory, replacing
694+
its `'~'`-suffix string hack, so draft numbers match published numbers.
695+
696+
One primitive backs all of them: `nextUtcCalendarDay` (`@objectstack/core`),
697+
which rejects instants, `Date`s and impossible days (`2026-02-30`) rather than
698+
inventing a bound. Half-open — never an inclusive `23:59:59.999`, which
699+
re-opens the gap at whatever precision the dialect stores beyond milliseconds
700+
(Postgres keeps microseconds), and is the same `[gte, lt)` shape the drill
701+
ranges (#1752) already emit. `< next-day` is also order-equivalent to `<= day`
702+
for `Field.date` text, which is what lets the type-blind emitters (raw SQL,
703+
preview) apply it unconditionally; the driver, which knows the column type,
704+
scopes the rewrite to `datetime` so `date`/`time` columns compile byte-identical
705+
to before.
706+
707+
The filter-token resolver (`filter-tokens.ts`) keeps its documented refusal to
708+
widen: a resolver-side fix would change what a token *is*; the emitter-side fix
709+
changes what a comparison *does* with it, per column type — which is the layer
710+
that owns that knowledge.
711+
712+
### D-D2 — Consequences for D-A3
713+
714+
The conformance matrix gains a **bound-semantics** axis (`point`, `whole-day`):
715+
row-result coverage for the `$lte`/`$between` upper-bound cells now lives in
716+
`sql-driver-calendar-day-upper-bound.test.ts` (canonical + legacy-mixed
717+
storage, dialect physical forms, boundary rollovers) and the strategy/preview
718+
suites; the full matrix program (relative-token × live-driver × timezone)
719+
remains open under D-A3.

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

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
// database, and midnight must land exactly on the day boundary in that zone.
77

88
import { describe, it, expect } from 'vitest';
9-
import { zonedDateStartToUtcMs, calendarPartsInTz } from './datetime.js';
9+
import { zonedDateStartToUtcMs, calendarPartsInTz, nextUtcCalendarDay } from './datetime.js';
1010

1111
const iso = (s: string) => Date.parse(s);
1212

@@ -60,3 +60,29 @@ describe('zonedDateStartToUtcMs — round-trips to the day boundary in the zone'
6060
});
6161
}
6262
});
63+
64+
describe('nextUtcCalendarDay — the exclusive upper bound of a bare calendar day (#3777)', () => {
65+
it('advances one day, rolling month, year and leap boundaries', () => {
66+
expect(nextUtcCalendarDay('2026-07-28')).toBe('2026-07-29');
67+
expect(nextUtcCalendarDay('2026-07-31')).toBe('2026-08-01');
68+
expect(nextUtcCalendarDay('2026-12-31')).toBe('2027-01-01');
69+
expect(nextUtcCalendarDay('2024-02-28')).toBe('2024-02-29'); // leap year
70+
expect(nextUtcCalendarDay('2025-02-28')).toBe('2025-03-01');
71+
expect(nextUtcCalendarDay(' 2026-07-28 ')).toBe('2026-07-29'); // trimmed
72+
});
73+
74+
it('returns null for anything that is not a valid bare calendar day', () => {
75+
// Instants keep instant semantics — never widened.
76+
expect(nextUtcCalendarDay('2026-07-28T12:00:00Z')).toBeNull();
77+
expect(nextUtcCalendarDay(new Date('2026-07-28T00:00:00Z'))).toBeNull();
78+
// Impossible days are rejected, not rolled into an invented bound.
79+
expect(nextUtcCalendarDay('2026-02-30')).toBeNull();
80+
expect(nextUtcCalendarDay('2026-13-01')).toBeNull();
81+
// Non-strings / junk.
82+
expect(nextUtcCalendarDay(1753660800000)).toBeNull();
83+
expect(nextUtcCalendarDay(null)).toBeNull();
84+
expect(nextUtcCalendarDay(undefined)).toBeNull();
85+
expect(nextUtcCalendarDay('7/28/2026')).toBeNull();
86+
expect(nextUtcCalendarDay('2026-7-28')).toBeNull(); // not zero-padded → not the canonical shape
87+
});
88+
});

packages/core/src/utils/datetime.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,39 @@ export function zonedDateStartToUtcMs(ymd: string, tz?: string): number {
102102
}
103103
}
104104

105+
/**
106+
* The calendar day after a bare `YYYY-MM-DD` string — the exclusive upper
107+
* bound of that day, for compiling "through day X" into the half-open
108+
* `[X, X+1)` a `datetime` column needs (#3777).
109+
*
110+
* A bare calendar day used as an upper bound (`$lte`, a `dateRange` end, a
111+
* `{current_month_end}` token) always carries whole-day intent — the author
112+
* means "including everything that happened on X", never "up to the stroke of
113+
* midnight that begins X". On a `date` column `<= X` already says that; on a
114+
* `datetime` column it silently drops every instant after 00:00. The correct
115+
* translation is the half-open pair `>= start AND < nextUtcCalendarDay(end)`
116+
* — the same shape the analytics drill ranges already emit — rather than an
117+
* inclusive `23:59:59.999` constant, which re-opens the gap at whatever
118+
* precision the dialect stores beyond milliseconds.
119+
*
120+
* `< nextUtcCalendarDay(X)` is also *equivalent* to `<= X` for a `date` column
121+
* (plain `YYYY-MM-DD` text ordering), so emitters that cannot see the column
122+
* type (raw-SQL strategies, the dataset preview evaluator) can apply it
123+
* unconditionally to a bare-day bound and be right on both column types.
124+
*
125+
* Returns `null` for anything that is not a valid bare calendar day — full
126+
* ISO timestamps keep instant semantics and must NOT be widened, and an
127+
* impossible day (`2026-02-30`) is rejected the same way
128+
* {@link bucketKeyToCalendarRange} rejects it, so a caller falls back to the
129+
* untranslated comparand instead of inventing a bound.
130+
*/
131+
export function nextUtcCalendarDay(value: unknown): string | null {
132+
if (typeof value !== 'string') return null;
133+
const day = value.trim();
134+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return null;
135+
return bucketKeyToCalendarRange(day, 'day')?.end ?? null;
136+
}
137+
105138
/**
106139
* Granularity of a canonical date-bucket key. Mirrors `@objectstack/spec`'s
107140
* `DateGranularity` enum but kept as a local literal union so this low-level

0 commit comments

Comments
 (0)