Skip to content

Commit 1bd14c9

Browse files
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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/core': minor
3+
---
4+
5+
fix(core)!: a date macro whose offset lands past every instant a JavaScript `Date` can hold is refused `INVALID_FILTER` / 400, naming the placeholder, instead of resolving to the text `Invalid Date` or throwing an uncoded `RangeError`
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of a VALUE in @objectstack/core's filter-placeholder resolver: a date macro (such as {300000_years_ago} or {99999999999999999999_minutes_ago}) whose offset lands past every instant a JavaScript Date can hold, refused INVALID_FILTER / 400 by resolveFilterToken and resolveFilterTokens. No authorable key, spelling, export or stored metadata shape moves: every filter, view, dataset and query shape parses as before, the date-macro vocabulary is unchanged, and @objectstack/core exports nothing new and nothing less (resolveFilterToken and resolveFilterTokens keep their signatures; the error class is module-private). The categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers a value range or a resolved value and this diff adds none (not registered / already-registered); and the change is runtime behaviour, not a declaration (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING**: this narrows what `resolveFilterToken` and `resolveFilterTokens` answer for one class of inputs, and so what every door that resolves filter placeholders answers. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
12+
13+
**What is refused now.** A relative-date placeholder whose offset lands past the instants a JavaScript `Date` can hold names no day and no time. The resolver used to answer a day-or-coarser one (`{300000_years_ago}`) with the text `Invalid Date`, which compares as text, and to throw an uncoded `RangeError: Invalid time value` for a sub-day one (`{99999999999999999999_minutes_ago}`). Measured before this through `engine.find` on InMemoryDriver and `POST /api/v1/data/:object/query` on SqlDriver over SQLite, over a `datetime` field with a row in 2026 and a row in 1500:
14+
15+
- `$lt {300000_years_ago}` answered both rows, and `judgeFilter` answered `{ ok: true }`;
16+
- `$lt {99999999999999999999_minutes_ago}` threw the uncoded `RangeError` from `engine.find` and `judgeFilter`, and the REST door answered `500 INTERNAL_ERROR`.
17+
18+
Both are refused now with `INVALID_FILTER` / 400 at every position the engine resolves (`where` on `find`, `findOne`, `count`, `aggregate`, a multi-row `update` and `delete`, a per-aggregation `filter`, `having`) and through `judgeFilter`, before any driver read. Every other caller of `resolveFilterToken` / `resolveFilterTokens` receives the same coded error in place of the text or the `RangeError`. The refusal is the same on every column, because the resolver does not know the column: such a placeholder names no value at all.
19+
20+
**What an author sees.** `Relative-date placeholder "{300000_years_ago}" names no instant: its offset lands past every instant a JavaScript Date can hold, so it resolves to no day and no time, and it is refused rather than compared. A date value names a year from 0001 to 9999, and a datetime value a year from 1000 to 9999: use a relative-date placeholder whose offset lands inside those years.` The thrown error carries `code: 'INVALID_FILTER'`, `status: 400` and `token` (the placeholder's name), the code the engine already answers for a placeholder that resolves outside its column's years.
21+
22+
**Unchanged.** A placeholder that names an instant resolves as before, including one past the years 0001..9999 that a `Date` still holds (`{273847_years_ago}` resolves to `-271821-09-30`); the engine's per-column year range judges that one, as before. Context placeholders and an unknown placeholder answer as before.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#21068] A date macro whose offset lands past every instant a JavaScript
4+
// `Date` can hold names no day and no time. Measured on the base
5+
// (`fed0db8f6`), off 2026-09-30T12:00Z: `{300000_years_ago}` resolved to the
6+
// text `Invalid Date`, and `{99999999999999999999_minutes_ago}` threw an
7+
// uncoded `RangeError: Invalid time value` from `toISOString`. Both are
8+
// refused now, `INVALID_FILTER` / 400, naming the placeholder.
9+
//
10+
// Pins: every unit of the parameterised grammar in both directions, through
11+
// `resolveFilterToken` and through `resolveFilterTokens` on a filter tree;
12+
// the `Date`'s own edges on both sides, resolved (the year is then the
13+
// engine's to judge); and in-range controls for a day and an instant.
14+
15+
import { describe, it, expect } from 'vitest';
16+
import { resolveFilterToken, resolveFilterTokens } from './filter-tokens.js';
17+
18+
// Wed 2026-09-30 12:00 UTC: every value below is read off this instant.
19+
const NOW = new Date('2026-09-30T12:00:00.000Z');
20+
21+
const refusalOf = (run: () => unknown) => {
22+
try {
23+
run();
24+
} catch (e) {
25+
return e as Error & { code?: unknown; status?: unknown; token?: unknown };
26+
}
27+
return null;
28+
};
29+
30+
/** Every unit, both directions: each offset lands past the instants a `Date` holds. */
31+
const PAST_THE_DATE_RANGE: readonly string[] = [
32+
'300000_years_ago',
33+
'99999999999999999999_minutes_ago',
34+
'300000_years_from_now',
35+
'9999999_months_ago',
36+
'99999999_weeks_from_now',
37+
'100020727_days_ago',
38+
'99999999999_hours_from_now',
39+
'144029846161_minutes_ago',
40+
];
41+
42+
describe('[#21068] a date macro past the instants a Date holds is refused, naming the placeholder', () => {
43+
it.each(PAST_THE_DATE_RANGE)('{%s}: INVALID_FILTER / 400, the token named, never the text "Invalid Date"', (token) => {
44+
const refusal = refusalOf(() => resolveFilterToken(token, { now: NOW }));
45+
expect(refusal, `{${token}} resolved instead of refusing`).not.toBeNull();
46+
expect(refusal!.code).toBe('INVALID_FILTER');
47+
expect(refusal!.status).toBe(400);
48+
expect(refusal!.token).toBe(token);
49+
expect(refusal!.message).toContain(`"{${token}}"`);
50+
expect(refusal!.message).toContain('past every instant a JavaScript Date can hold');
51+
expect(refusal!.message).toContain('a datetime value a year from 1000 to 9999');
52+
expect(refusal!.message).not.toContain('Invalid Date');
53+
expect(refusal!.message).not.toContain('Invalid time value');
54+
});
55+
56+
it('resolveFilterTokens refuses the same in a filter tree, in the timezone path too, and leaves the tree unmutated', () => {
57+
for (const token of ['300000_years_ago', '99999999999999999999_minutes_ago']) {
58+
for (const timezone of [undefined, 'Asia/Shanghai']) {
59+
const filter = { $and: [{ opened_at: { $lt: `{${token}}` } }, { note: 'x' }] };
60+
const refusal = refusalOf(() => resolveFilterTokens(filter, { now: NOW, timezone }));
61+
expect(refusal?.code, `{${token}} in ${timezone ?? 'UTC'}`).toBe('INVALID_FILTER');
62+
expect(refusal?.status).toBe(400);
63+
expect(refusal?.message).toContain(`"{${token}}"`);
64+
expect(filter.$and[0]).toEqual({ opened_at: { $lt: `{${token}}` } });
65+
}
66+
}
67+
});
68+
69+
it('the Date\'s own edges resolve: the last day and the last minute it holds, on both sides', () => {
70+
// The refusal is the `Date`'s validity, not a copy of its range: one step
71+
// inside the edge resolves, spelled as the day or instant it names, and its
72+
// year is the engine's to judge against its column.
73+
expect(resolveFilterToken('273847_years_ago', { now: NOW })).toBe('-271821-09-30');
74+
expect(refusalOf(() => resolveFilterToken('273848_years_ago', { now: NOW }))?.code).toBe('INVALID_FILTER');
75+
expect(resolveFilterToken('273733_years_from_now', { now: NOW })).toBe('+275759-09-30');
76+
expect(refusalOf(() => resolveFilterToken('273734_years_from_now', { now: NOW }))?.code).toBe('INVALID_FILTER');
77+
// 144029846160 minutes before NOW is exactly -8.64e15 ms, the first instant a Date holds.
78+
expect(resolveFilterToken('144029846160_minutes_ago', { now: NOW })).toBe(new Date(-8.64e15).toISOString());
79+
expect(refusalOf(() => resolveFilterToken('144029846161_minutes_ago', { now: NOW }))?.code).toBe('INVALID_FILTER');
80+
});
81+
82+
it('the controls: a day and an instant inside the range resolve as before', () => {
83+
expect(resolveFilterToken('100_years_ago', { now: NOW })).toBe('1926-09-30');
84+
expect(resolveFilterToken('1_hour_ago', { now: NOW })).toBe('2026-09-30T11:00:00.000Z');
85+
expect(resolveFilterToken('now', { now: NOW })).toBe(NOW.toISOString());
86+
expect(resolveFilterTokens({ opened_at: { $lt: '{100_years_ago}' } }, { now: NOW })).toEqual({
87+
opened_at: { $lt: '1926-09-30' },
88+
});
89+
});
90+
});

‎packages/core/src/utils/filter-tokens.ts‎

Lines changed: 116 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,19 @@
3737
* is the engine's question, not this module's: it refuses a resolved token
3838
* outside its field's years.
3939
*
40+
* # A date macro past the instants a `Date` holds refuses here
41+
*
42+
* [#21068] An offset can land past every instant a JavaScript `Date` can hold
43+
* (ECMA-262's time values, ±8.64e15 ms around 1970): `{300000_years_ago}`,
44+
* `{99999999999999999999_minutes_ago}`. Such a macro names no day and no time,
45+
* so there is no year left for the engine to judge. It used to resolve to the
46+
* text `Invalid Date` (a day-or-coarser macro), which compares as text and
47+
* answered every row of a `$lt`, or to throw an uncoded `RangeError` from
48+
* `toISOString` (a sub-day one), which the REST door answered `500`. It is
49+
* refused instead, `INVALID_FILTER` / 400 naming the placeholder — see
50+
* {@link DateMacroPastDateRangeError}. The test is the `Date`'s own validity,
51+
* asked once, of the instant the macro computed: ⛔ never a copy of the range.
52+
*
4053
* # Period `_end` resolves to a calendar DAY — its WIDTH is ADR-0053 D-D
4154
*
4255
* `{current_year_end}` resolves to `2026-12-31`, per the spec's own
@@ -97,8 +110,13 @@ import {
97110
parseDateMacroParam,
98111
type DateMacroUnit,
99112
} from '@objectstack/spec/data';
113+
import type { StandardErrorCode } from '@objectstack/spec/api';
100114
import { calendarPartsInTzOrUtc, wallClockToUtcMs } from './datetime.js';
101-
import { isOutsideTemporalYearRange, temporalStorageForm } from './temporal-storage-form.js';
115+
import {
116+
isOutsideTemporalYearRange,
117+
SUPPORTED_TEMPORAL_YEARS,
118+
temporalStorageForm,
119+
} from './temporal-storage-form.js';
102120

103121
/**
104122
* The slice of an execution context the resolver reads. Structural on purpose —
@@ -187,6 +205,46 @@ export class UnresolvedFilterTokenError extends Error {
187205
}
188206
}
189207

208+
/** A year as a refusal spells it: four digits, `0001`. */
209+
const fourDigitYear = (year: number): string => String(year).padStart(4, '0');
210+
211+
/**
212+
* [#21068] Raised when a date macro's offset lands past every instant a
213+
* JavaScript `Date` can hold, so the macro names no day and no time — see the
214+
* module doc. `token` names the placeholder.
215+
*
216+
* `INVALID_FILTER` / 400, the code a date macro's family already answers: the
217+
* engine refuses one that resolved outside its column's years with it, and the
218+
* temporal-comparand door a literal of the same value. This one lands past
219+
* every column's years, and the author's fix is the same (a smaller offset).
220+
* ⛔ Not `FILTER_TOKEN_UNRESOLVED`: its declared meaning is a context the
221+
* request does not carry ({@link UnresolvedFilterTokenError}: "the spelling is
222+
* correct, the context is not"), and here the context is complete. ⛔ Not
223+
* `FILTER_TOKEN_UNKNOWN` either: the token is in the vocabulary.
224+
*
225+
* Not exported: a caller reads `code` and `status`, as for every door's
226+
* refusal, and the module's public surface stays as it was.
227+
*/
228+
class DateMacroPastDateRangeError extends Error {
229+
readonly token: string;
230+
readonly status = 400;
231+
readonly code = 'INVALID_FILTER' satisfies StandardErrorCode;
232+
233+
constructor(token: string) {
234+
const { date, datetime } = SUPPORTED_TEMPORAL_YEARS;
235+
super(
236+
`Relative-date placeholder "{${token}}" names no instant: its offset lands past every instant ` +
237+
'a JavaScript Date can hold, so it resolves to no day and no time, and it is refused rather ' +
238+
`than compared. A date value names a year from ${fourDigitYear(date.first)} to ` +
239+
`${fourDigitYear(date.last)}, and a datetime value a year from ${fourDigitYear(datetime.first)} ` +
240+
`to ${fourDigitYear(datetime.last)}: use a relative-date placeholder whose offset lands inside ` +
241+
'those years.',
242+
);
243+
this.name = 'DateMacroPastDateRangeError';
244+
this.token = token;
245+
}
246+
}
247+
190248
/**
191249
* Calendar arithmetic is done on a UTC "proxy" date built from the reference
192250
* timezone's calendar parts. Working in UTC keeps the math free of DST jumps
@@ -220,7 +278,8 @@ function proxyDay(now: Date, timezone?: string): Date {
220278
* `toISOString` spells for its instant: every reader reads it as that UTC day,
221279
* on every host. The range is core's one range, asked of the day itself, never
222280
* re-derived here. A step past the instants a `Date` holds is not one of these:
223-
* it has no day at all, and keeps the rule's spelling of an invalid `Date`.
281+
* it has no day at all, and [#21068] never reaches here — {@link resolveFilterToken}
282+
* refuses it first.
224283
*/
225284
function asYmd(d: Date): string {
226285
if (!isOutsideTemporalYearRange(d, 'date')) return String(temporalStorageForm(d, 'date'));
@@ -306,12 +365,12 @@ function addUnits(unit: DateMacroUnit, d: Date, n: number): Date {
306365

307366
/**
308367
* `current|last|next` × `week|month|quarter|year` × `start|end`, plus the bare
309-
* `week_start`-style aliases (which mean `current_`). Returns `undefined` when
310-
* the token is not a period token.
368+
* `week_start`-style aliases (which mean `current_`). Returns the period's
369+
* bounding day, or `undefined` when the token is not a period token.
311370
*/
312371
const PERIOD_RE = /^(?:(current|last|next)_)?(week|month|quarter|year)_(start|end)$/;
313372

314-
function resolvePeriodToken(token: string, today: Date): string | undefined {
373+
function resolvePeriodToken(token: string, today: Date): Date | undefined {
315374
const m = PERIOD_RE.exec(token);
316375
if (!m) return undefined;
317376
const rel = (m[1] ?? 'current') as 'current' | 'last' | 'next';
@@ -324,19 +383,59 @@ function resolvePeriodToken(token: string, today: Date): string | undefined {
324383
// is involved at all — so the answer never depends on the clamp policy, and
325384
// the arithmetic reads the same for every `kind`.
326385
const periodStart = startOfPeriod(kind, addPeriods(kind, startOfPeriod(kind, today), offset));
327-
if (bound === 'start') return asYmd(periodStart);
386+
if (bound === 'start') return periodStart;
328387
// `_end` = the last calendar DAY of the period: the day before the next
329388
// period begins. See the module doc on half-open ranges.
330389
const next = addPeriods(kind, periodStart, 1);
331390
next.setUTCDate(next.getUTCDate() - 1);
332-
return asYmd(next);
391+
return next;
392+
}
393+
394+
/**
395+
* [#21068] The instant a date macro computes, and how it is spelled: an
396+
* instant (`{now}`, `{N_hours_*}`, `{N_minutes_*}`) or a calendar day (every
397+
* other macro). `undefined` when `token` is not a date macro. Computing it
398+
* apart from spelling it lets {@link resolveFilterToken} ask the one question
399+
* every macro owes before either spelling — does the instant exist? — in one
400+
* place.
401+
*/
402+
function dateMacroInstant(
403+
token: string,
404+
now: Date,
405+
timezone: string | undefined,
406+
): { at: Date; spelled: 'instant' | 'day' } | undefined {
407+
const today = proxyDay(now, timezone);
408+
409+
switch (token) {
410+
case 'now': return { at: now, spelled: 'instant' };
411+
case 'today': return { at: today, spelled: 'day' };
412+
case 'yesterday': return { at: addUnits('day', today, -1), spelled: 'day' };
413+
case 'tomorrow': return { at: addUnits('day', today, 1), spelled: 'day' };
414+
}
415+
416+
const period = resolvePeriodToken(token, today);
417+
if (period !== undefined) return { at: period, spelled: 'day' };
418+
419+
const param = parseDateMacroParam(token);
420+
if (!param) return undefined;
421+
const sign = param.direction === 'ago' ? -1 : 1;
422+
// Sub-day units are instants — they must keep their time-of-day, so they
423+
// shift `now` and render as a full ISO timestamp. Day-and-coarser units are
424+
// calendar quantities and render as `YYYY-MM-DD` off the reference day.
425+
if (param.unit === 'minute' || param.unit === 'hour') {
426+
return { at: addUnits(param.unit, now, sign * param.n), spelled: 'instant' };
427+
}
428+
return { at: addUnits(param.unit, today, sign * param.n), spelled: 'day' };
333429
}
334430

335431
/**
336432
* Resolve one token NAME (the bit inside the braces) to its concrete value.
337433
* Throws {@link UnresolvedFilterTokenError} for a vocabulary token the request
338-
* carries no value for. Returns `undefined` only when the token is outside the
339-
* vocabulary — callers turn that into {@link UnknownFilterTokenError}.
434+
* carries no value for, and [#21068] `INVALID_FILTER` / 400 for a date macro
435+
* whose offset lands past every instant a `Date` holds
436+
* ({@link DateMacroPastDateRangeError}). Returns `undefined` only when the
437+
* token is outside the vocabulary — callers turn that into
438+
* {@link UnknownFilterTokenError}.
340439
*/
341440
export function resolveFilterToken(
342441
token: string,
@@ -385,31 +484,14 @@ export function resolveFilterToken(
385484
}
386485

387486
// ── Date macros ───────────────────────────────────────────────────────
388-
const today = proxyDay(now, ctx.timezone);
389-
390-
switch (token) {
391-
case 'now': return now.toISOString();
392-
case 'today': return asYmd(today);
393-
case 'yesterday': return asYmd(addUnits('day', today, -1));
394-
case 'tomorrow': return asYmd(addUnits('day', today, 1));
395-
}
396-
397-
const period = resolvePeriodToken(token, today);
398-
if (period !== undefined) return period;
399-
400-
const param = parseDateMacroParam(token);
401-
if (param) {
402-
const sign = param.direction === 'ago' ? -1 : 1;
403-
// Sub-day units are instants — they must keep their time-of-day, so they
404-
// shift `now` and render as a full ISO timestamp. Day-and-coarser units are
405-
// calendar quantities and render as `YYYY-MM-DD` off the reference day.
406-
if (param.unit === 'minute' || param.unit === 'hour') {
407-
return addUnits(param.unit, now, sign * param.n).toISOString();
408-
}
409-
return asYmd(addUnits(param.unit, today, sign * param.n));
410-
}
411-
412-
return undefined;
487+
const macro = dateMacroInstant(token, now, ctx.timezone);
488+
if (macro === undefined) return undefined;
489+
// [#21068] An offset past every instant a `Date` holds computes an invalid
490+
// `Date`: `asYmd` would spell it as the text `Invalid Date` and
491+
// `toISOString` would throw an uncoded `RangeError`. Refused here, once,
492+
// before either spelling.
493+
if (Number.isNaN(macro.at.getTime())) throw new DateMacroPastDateRangeError(token);
494+
return macro.spelled === 'instant' ? macro.at.toISOString() : asYmd(macro.at);
413495
}
414496

415497
/**

0 commit comments

Comments
 (0)