Commit 1a75e39
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
File tree
- .changeset
- packages
- core/src/utils
- drivers
- driver-memory/src
- driver-mongodb/src
- driver-sql/src
- driver-turso/src
- formula/src
- objectql/src
- rest/src
- services/service-analytics/src
- __tests__
- strategies
- spec
- api-surface
- export-origins
- src/data
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
316 | 316 | | |
317 | 317 | | |
318 | 318 | | |
319 | | - | |
320 | | - | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
321 | 323 | | |
322 | 324 | | |
323 | 325 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
192 | 192 | | |
193 | 193 | | |
194 | 194 | | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
195 | 198 | | |
196 | | - | |
| 199 | + | |
| 200 | + | |
197 | 201 | | |
198 | 202 | | |
199 | 203 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
11 | 11 | | |
12 | 12 | | |
13 | 13 | | |
| 14 | + | |
14 | 15 | | |
15 | 16 | | |
16 | 17 | | |
| |||
266 | 267 | | |
267 | 268 | | |
268 | 269 | | |
| 270 | + | |
| 271 | + | |
269 | 272 | | |
270 | 273 | | |
| 274 | + | |
271 | 275 | | |
272 | 276 | | |
273 | 277 | | |
| |||
458 | 462 | | |
459 | 463 | | |
460 | 464 | | |
| 465 | + | |
| 466 | + | |
461 | 467 | | |
462 | 468 | | |
| 469 | + | |
463 | 470 | | |
464 | 471 | | |
465 | 472 | | |
| |||
991 | 998 | | |
992 | 999 | | |
993 | 1000 | | |
994 | | - | |
| 1001 | + | |
| 1002 | + | |
| 1003 | + | |
| 1004 | + | |
| 1005 | + | |
| 1006 | + | |
| 1007 | + | |
995 | 1008 | | |
996 | 1009 | | |
997 | 1010 | | |
998 | 1011 | | |
999 | | - | |
1000 | | - | |
1001 | | - | |
1002 | | - | |
1003 | | - | |
1004 | | - | |
| 1012 | + | |
| 1013 | + | |
| 1014 | + | |
| 1015 | + | |
| 1016 | + | |
| 1017 | + | |
| 1018 | + | |
| 1019 | + | |
| 1020 | + | |
| 1021 | + | |
1005 | 1022 | | |
1006 | 1023 | | |
1007 | 1024 | | |
| |||
Lines changed: 101 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
20 | | - | |
| 20 | + | |
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
| |||
153 | 153 | | |
154 | 154 | | |
155 | 155 | | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
0 commit comments