Repository navigation
Commit 1bd14c9
fix(core)!: a date macro whose offset lands past every instant a JavaScript Date holds is refused INVALID_FILTER / 400, naming the placeholder (#21123)
Fixes #21068
Clause-②: no (narrowing)
A date macro whose offset lands past every instant a JavaScript `Date`
can hold (`{300000_years_ago}`, `{99999999999999999999_minutes_ago}`)
names no day and no time. `@objectstack/core`'s resolver now refuses it
with `INVALID_FILTER` / 400, naming the placeholder. It used to resolve
a day macro to the text `Invalid Date`, which compares as text, and to
throw an uncoded `RangeError` from `toISOString` for a sub-day macro,
which the REST door answered with a 500.
Dispatched by the PM loop (round 1, seat `domain:engine#2`), claim
comment 5925905189, session `session_01Ujdtvqs7ree7WyQmEDwEnG`.
## What changes
- **`packages/core/src/utils/filter-tokens.ts`.** `resolveFilterToken`
now computes a date macro's instant separately from spelling it
(`dateMacroInstant`: the `Date`, and whether it is spelled as an instant
or as a calendar day). It asks one question of that instant before
either spelling: is it a valid `Date`? If not, it throws
`DateMacroPastDateRangeError`: `code: 'INVALID_FILTER'`, `status: 400`,
and `token` set to the placeholder's name. `resolvePeriodToken` returns
the period's bounding `Date` rather than its spelling. That is internal,
and the spelling of every day and instant a `Date` holds is unchanged.
- The check is the `Date`'s own validity, not a copy of its range. One
step inside the edge still resolves: `{273847_years_ago}` resolves to
`-271821-09-30`, and the engine's per-column year judge (PR #21065)
refuses it on a `date` or `datetime` column, as before.
- The error class is not exported, so `@objectstack/core` publishes no
new symbol. Callers read `code` and `status`, as for every door's
refusal. Its message names the years each kind takes from
`SUPPORTED_TEMPORAL_YEARS`, never a copy of the numbers.
- Nothing else in core, objectql or rest changes. There is no second
validity or range check outside the resolver, and the engine's
year-range judge is untouched.
## The PM's hypotheses (zone 2)
- **H1, reproduced** on `origin/main` `fed0db8f6` with a scratch probe
(not committed), off 2026-09-30T12:00Z, on the card's object
(`opened_at` `datetime`, rows at 2026-03-01T10:00Z and
1500-03-01T10:00Z). The memory column is `engine.find` on
InMemoryDriver. The SQLite column is `POST /api/v1/data/:object/query`
on SqlDriver over SQLite (better-sqlite3).
| `where` | resolved to (base) | base, memory | base, SQLite | base,
`judgeFilter` | now, all three |
|:--|:--|:--|:--|:--|:--|
| `opened_at $lt {300000_years_ago}` | the text `Invalid Date` | both
rows | 200, both rows | `{ ok: true }` | 400 `INVALID_FILTER`, the
placeholder named |
| `opened_at $lt {99999999999999999999_minutes_ago}` | throws
`RangeError: Invalid time value` | uncoded `RangeError` | 500
`INTERNAL_ERROR` | throws `RangeError` | 400 `INVALID_FILTER` |
| `placed_on` (`date`) `$lt {300000_years_ago}` | `Invalid Date` | both
rows | 200, both rows | `{ ok: true }` | 400 |
| `opened_at $gt {300000_years_from_now}` | `Invalid Date` | no row
(compared as text) | 200, no row | `{ ok: true }` | 400 |
| `opened_at $lt {99999999999999999999_hours_ago}` | throws `RangeError`
| uncoded `RangeError` | 500 | throws | 400 |
| control: `opened_at $lt {100_years_ago}` | `1926-09-30` | the 1500 row
| 200, the 1500 row | `{ ok: true }` | unchanged |
The "now" column was measured with the same probe on this branch at
`290ed978e`, with core's `dist/` rebuilt from the fix.
- **H2, the code: `INVALID_FILTER`.** I read the declared meanings of
the three candidates:
- `FILTER_TOKEN_UNRESOLVED` means a context the request does not carry.
Every place that states it says so: the class docblock ("a token IS in
the vocabulary but the request carries no value for it … the spelling is
correct, the context is not"), the spec contract
`IObjectQLEngine.judgeFilter` ("A placeholder whose value this context
does not carry is REFUSED (`FILTER_TOKEN_UNRESOLVED`)"), `engine.ts`'s
resolution-stage comment, and the published CHANGELOG entries
(`{current_user_id}` with no user, `{record_id}`). Here the context is
complete, and the author's fix is a smaller offset, not a context. So
that code reads false.
- `FILTER_TOKEN_UNKNOWN` means outside the vocabulary. The token is in
the vocabulary (`classifyFilterToken` says `date-macro`), so that code
reads false too.
- `INVALID_FILTER` is the standard catalog's "Invalid filter
expression". It is also the code this family already answers: the engine
refuses a macro that resolved outside its column's years with it (PR
#21065), and the temporal-comparand door refuses a literal of the same
value with it. It reads true, and an author gets one code across the
whole offset axis: `{273847_years_ago}` is refused by the engine's year
judge, and `{273848_years_ago}` by the resolver.
- No code is minted: `INVALID_FILTER` is a standard member and needs no
ledger row.
- **H3, one place, confirmed.** Both failures came from an invalid
`Date` reaching a spelling: `asYmd` gave `String(date)`, and
`toISOString` threw. The check sits right after the instant is computed
and before either spelling, so it covers every date macro (fixed, period
and parameterised) on both forms. `resolveFilterTokens` and every caller
of `resolveFilterToken` inherit it.
## Pins
- `packages/core/src/utils/filter-tokens-past-date-range.test.ts` covers
every unit of the parameterised grammar in both directions. Each case
asserts `code` `INVALID_FILTER`, `status` 400, `token`, the placeholder
in the message, and that neither `Invalid Date` nor `Invalid time value`
appears. It also covers `resolveFilterTokens` on a tree (UTC and
`Asia/Shanghai`, input not mutated); the `Date`'s own edges on both
sides (`{273847_years_ago}` resolves, `{273848_years_ago}` refuses; the
same for `_from_now` and for minutes at exactly -8.64e15 ms); and
in-range controls for a day, an instant and `{now}`.
- `packages/objectql/src/engine-resolved-token-past-date-range.test.ts`
is the memory cell, held by a recording driver. Both rows of the card
are refused on `find`, `findOne`, `count`, multi-row `update` and
`delete`, `aggregate` `where`, a per-aggregation `filter` and `having`.
The column kinds are `datetime`, `date` and `text`; a text column's
`having` is left out because `max` of text is the aggregate field-type
door's to answer. The test asserts `code`, `status`, the placeholder
named, and no driver read. `judgeFilter` returns execution's exact
`code`, `status` and `message`. The control (`{100_years_ago}`,
`{1_hour_ago}`) reaches the driver as the value it names.
- `packages/rest/src/data-resolved-token-past-date-range.test.ts` is the
SQLite cell, at `POST /api/v1/data/:object/query` on the card's two
rows. Both placeholders get 400 `INVALID_FILTER` naming the placeholder,
at `where`, the per-aggregation `filter` and `having`, with no read of
the object, and the body never contains `Invalid Date`. The control
answers the right rows, filter counts and `having` groups (`$lt` / `$gt
{100_years_ago}`, `$lt {1_hour_ago}`).
## Verification (head `381d735b8`, unless a line says otherwise)
- `@objectstack/core`: `pnpm --filter @objectstack/core test` passed 72
files / 2084 tests. `typecheck` passed, including
`check:test-typecheck`; `tsc -p tsconfig.test.json --listFiles` lists
the new test (1 hit).
- `@objectstack/objectql`, against core's rebuilt `dist/`:
- the full `vitest run --project local` passed 356 files / 6919 tests at
`1dbee5b9b`. The later merge of `origin/main` touched no objectql file,
and in core only the import-runner modules and `index.ts`.
- At `381d735b8`, the six token, judge and temporal-door suites passed
(131 tests), and `typecheck` passed.
- `@objectstack/rest`: `typecheck` passed. The new pin plus five
placeholder and temporal suites (`data-resolved-token-year-range`,
`data-temporal-year-range`, `rest-aggregate-positions`,
`data-number-comparand-door`, `analytics-dataset-selection-door`) passed
51, with 16 named skips for unprovisioned live PG / MySQL cells. The
full rest suite is declared to CI.
- `@objectstack/service-analytics` (consumer, at `1dbee5b9b`):
`query-filter-tokens`, `dataset-filter-tokens`,
`read-scope-placeholder-three-faces` and
`objectql-read-scope-placeholder-refusal` passed 64.
- **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `381d735b8` derived 65 commands. All 65
were run at that head: 63 exited 0, and `--ran` with the exit codes
reconciles 65 / 65.
- **NOT MEASURED: `pnpm check:dual-build-cjs-loads` and `pnpm
check:type-check-debt`, reason: exit 3 PREREQUISITE NOT MET.** Both read
every workspace package's built `dist/`, and only the rest closure was
built here. Declared to CI.
- In their place, a narrowed probe: core's `dist/index.cjs` loads under
`require` and answers the refusal with its code, and neither the CJS nor
the ESM entry exports `DateMacroPastDateRangeError`.
- `check:adr-0087-registration` reads the changeset as `not-required
(no-migration-prescription)`. `check:changeset-no-major` and
`check:empty-changeset` are green. `scripts/pm/check-widening-tells.mjs
--declaration no` over the PR diff exits 0.
- `node scripts/check-driver-conformance.mjs`: before (`fed0db8f6`) "50
covered cell(s), 0 in the DEBT ledger, 0 exempt"; after (`381d735b8`)
the same.
## Reverse verification (ablation, one-off, not committed)
At `1dbee5b9b`, with the fix committed, I deleted the guard line in
`filter-tokens.ts` through `scripts/ablation-replace.mjs` (wrap mode,
anchor 1 to 0, blob `9c727e33e` to `becaf90bf`). Its restore was armed,
and an outer `trap … EXIT INT TERM` restored from `HEAD`.
- **Mutate leg.** Core was rebuilt, and
`scripts/ablation-dist-preflight.mjs @objectstack/core
'Number.isNaN(macro.at.getTime())' --absent` passed (marker absent from
all 14 built files; before the mutation it was present in
`dist/index.js` and `dist/index.cjs`). The three pins went red, the
usual direction:
- core 10 / 11 (only the controls held);
- objectql 2 / 3 (`find where opened_at $lt {300000_years_ago}`:
resolved instead of refusing);
- rest 1 / 2 (200 with both rows instead of 400).
- **Restore leg.** The blob equals `HEAD` (`9c727e33e`), `git diff HEAD`
is empty and `git status --porcelain` is empty. Core was rebuilt, and
the preflight in default mode found the marker present in 2 built files.
The pins went back to 11 / 11, 3 / 3 and 2 / 2.
## Acceptance notes
- **Memory cell.** As with PR #21065, the memory row is pinned by
objectql's recording driver: the refusal answers before any driver is
asked, and neither `objectql` nor `rest` depends on `driver-memory`. The
InMemoryDriver readings in the table come from the scratch probe through
`engine.find`.
- **PostgreSQL / MySQL** were not run (no live servers here). The
refusal precedes the driver.
- **Every resolver caller gets the same coded error.**
`service-analytics` (`analytics-service.ts`, `dataset-executor.ts`,
`read-scope-sql.ts`) receives `INVALID_FILTER` / 400 where it used to
receive the `Invalid Date` text or a `RangeError`. How each analytics
door wraps a thrown coded error is unchanged and was not measured end to
end here.
- **Observed, not filed (no named producer): authoring does not refuse
it.** `os validate` and the lint `validate-filter-tokens` accept any
`{N_unit_ago}`, because the grammar admits every N. A stored filter with
such an offset passes authoring and is now refused at run time with this
message. The same holds for this family's year range
(`{8000_years_from_now}`). No real producer writes such offsets, so this
stays a note.
- The doc comments that list the resolver's refusals (`engine.ts`
`resolveWhereFilterTokens`, spec `IObjectQLEngine.judgeFilter`) name
`FILTER_TOKEN_UNKNOWN` / `FILTER_TOKEN_UNRESOLVED` and do not mention
this third refusal. They remain true but are incomplete. Left alone:
they are outside this card's file surface.
- `content/docs/releases/` and every `CHANGELOG.md` are untouched. The
changeset is `.changeset/21068-macro-past-date-range.md`:
`@objectstack/core` minor, BREAKING, one ADR-0087 marker `not-required
(no-migration-prescription)`. That is the precedent of `05a7547c9` and
PR #21065: nothing an author can write is removed or renamed, and no
registered id covers a value range.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 250dec8 commit 1bd14c9
5 files changed
Lines changed: 552 additions & 34 deletions
File tree
- .changeset
- packages
- core/src/utils
- objectql/src
- rest/src
| 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 | + | |
| 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 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
37 | 37 | | |
38 | 38 | | |
39 | 39 | | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
40 | 53 | | |
41 | 54 | | |
42 | 55 | | |
| |||
97 | 110 | | |
98 | 111 | | |
99 | 112 | | |
| 113 | + | |
100 | 114 | | |
101 | | - | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
102 | 120 | | |
103 | 121 | | |
104 | 122 | | |
| |||
187 | 205 | | |
188 | 206 | | |
189 | 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 | + | |
190 | 248 | | |
191 | 249 | | |
192 | 250 | | |
| |||
220 | 278 | | |
221 | 279 | | |
222 | 280 | | |
223 | | - | |
| 281 | + | |
| 282 | + | |
224 | 283 | | |
225 | 284 | | |
226 | 285 | | |
| |||
306 | 365 | | |
307 | 366 | | |
308 | 367 | | |
309 | | - | |
310 | | - | |
| 368 | + | |
| 369 | + | |
311 | 370 | | |
312 | 371 | | |
313 | 372 | | |
314 | | - | |
| 373 | + | |
315 | 374 | | |
316 | 375 | | |
317 | 376 | | |
| |||
324 | 383 | | |
325 | 384 | | |
326 | 385 | | |
327 | | - | |
| 386 | + | |
328 | 387 | | |
329 | 388 | | |
330 | 389 | | |
331 | 390 | | |
332 | | - | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
333 | 429 | | |
334 | 430 | | |
335 | 431 | | |
336 | 432 | | |
337 | 433 | | |
338 | | - | |
339 | | - | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
340 | 439 | | |
341 | 440 | | |
342 | 441 | | |
| |||
385 | 484 | | |
386 | 485 | | |
387 | 486 | | |
388 | | - | |
389 | | - | |
390 | | - | |
391 | | - | |
392 | | - | |
393 | | - | |
394 | | - | |
395 | | - | |
396 | | - | |
397 | | - | |
398 | | - | |
399 | | - | |
400 | | - | |
401 | | - | |
402 | | - | |
403 | | - | |
404 | | - | |
405 | | - | |
406 | | - | |
407 | | - | |
408 | | - | |
409 | | - | |
410 | | - | |
411 | | - | |
412 | | - | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
413 | 495 | | |
414 | 496 | | |
415 | 497 | | |
| |||
0 commit comments