Skip to content
Merged
25 changes: 25 additions & 0 deletions .changeset/20844-resolved-token-year-range.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@objectstack/core': minor
'@objectstack/objectql': minor
---

fix(core,objectql)!: a relative-date placeholder that resolves outside its field's years is refused `INVALID_FILTER` / 400, naming the placeholder and the year it resolved to, instead of reaching the driver and answering the wrong rows

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) a refusal of a VALUE at the engine's filter-resolution stage: a relative-date placeholder (a date macro such as {8000_years_from_now}) whose resolved day or instant falls outside its column's years, refused INVALID_FILTER / 400 on where, a per-aggregation filter and having. 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 and @objectstack/objectql export nothing new and nothing less (resolveFilterToken and resolveFilterTokens keep their signatures). The resolver's spelling of a day outside 0001..9999 changes, and that day was never a value any reader read as the day it names. The other categories are closed on facts: both packages publish (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). -->

**BREAKING**: this narrows what the engine answers for a filter carrying a relative-date placeholder. A date macro is resolved after the temporal-comparand door, which steps around a placeholder, so the year range that door asks of a literal never saw the value one resolved to. It does now, through the same function, core's `isOutsideTemporalYearRange`, by the column's kind: a `date` takes the years 0001 to 9999 and a `datetime` 1000 to 9999. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.

**What is refused now.** A date macro whose resolved value falls outside its column's years, on a declared `date` or `datetime` field (or, on a `time` field, one that resolves to an instant whose UTC year has no four-digit spelling), at `where` (on `find`, `findOne`, `count`, `aggregate`, a multi-row `update` and `delete`), at a per-aggregation `filter`, at `having` (by the aggregated column's kind), and through `judgeFilter`. On REST that is `POST /api/v1/data/:object/query` and every other door that reads through the engine. Measured before this on InMemoryDriver and SqlDriver on SQLite, over a `datetime` field with a row in 2026 and a row in 1500:

- `$gt {8000_years_from_now}` answered both rows, and the right answer was none;
- `$lt {2027_years_ago}` answered the 1500 row, because the resolver spelled year -1 as `-1-10-01` and that text was read as a day in 2001, and the right answer was none;
- `$lt {1977_years_ago}` resolved to year 49, below the `datetime` floor of 1000, which now applies to a resolved placeholder as it does to a literal;
- on a `time` field, `$gt {8000_years_from_now}` answered every row: the `time` rule keeps no time of day from an instant whose UTC year has no four-digit spelling, so it compared as text. Such a placeholder is refused now in the words a literal of that instant gets.

**What an author sees.** The refusal names the field, the placeholder as written, its position, the value it resolved to and that value's year, in the temporal-comparand door's words for the year class: `filter on 'opened_at' compares a declared datetime field against "{8000_years_from_now}" at where.opened_at.$gt, a relative-date placeholder that resolved to "+010026-10-01" (the year 10026), an instant whose UTC year falls outside the years 1000 to 9999 …`. It ends by asking for a placeholder whose offset lands inside those years.

**The resolver's spelling** (`@objectstack/core`). A date macro that lands on a day outside 0001..9999 now resolves to that day in the expanded-year form of ECMAScript's date time string format, `+010026-10-01` or `-000001-10-01` (year 0 is `0000-10-01`). It used to take the storage rule's unpadded spelling, `10026-10-01` or `-1-10-01`, which `Date.parse` reads through the host's legacy parser in the host's zone, so a day in year -1 read as one in 2001 and could not be judged. Every consumer of `resolveFilterToken` and `resolveFilterTokens` sees the new spelling for such a day only. A day inside 0001..9999 and a sub-day placeholder's instant are spelled as before.

**Unchanged.** A placeholder that resolves inside its column's years answers as before; a `date` keeps the years 0001 to 0999, which a `datetime` refuses, and a `time` field reads the time of day of any instant with a four-digit year, year 0 included. A placeholder on a column with no temporal kind (text, number) and a context placeholder such as `{current_user_id}` are not judged by this range. Every literal comparand answers as before.
84 changes: 84 additions & 0 deletions packages/core/src/utils/filter-tokens-year-outside-range.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [#20844] A date macro that lands outside the years 0001..9999 resolves to a
// day spelled in the expanded-year form (`+010026-09-30`, `-000001-09-30`),
// so core's one range reads the year it resolved to, on every host. Before,
// it took the storage rule's unpadded spelling (`10026-09-30`, `-1-09-30`),
// which `Date.parse` reads through the host's legacy parser, in the host's
// zone: `-1-09-30` read as a day in 2001, so `isOutsideTemporalYearRange`
// judged `{2027_years_ago}` inside the range, for both kinds.
//
// Pins: both sides of 0001..9999, year 0, the edges inside, a 2026 control and
// a sub-day instant (spelled by `toISOString` already), in UTC and in
// Asia/Shanghai. The engine refuses each one outside its field's years;
// objectql's `engine-resolved-token-year-range.test.ts` pins that half.

import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import { resolveFilterToken, resolveFilterTokens } from './filter-tokens.js';
import { isOutsideTemporalYearRange } from './temporal-storage-form.js';

// Wed 2026-09-30 12:00 UTC: the same calendar day in UTC and in Asia/Shanghai.
const NOW = new Date('2026-09-30T12:00:00.000Z');

const at = (token: string, timezone?: string) => resolveFilterToken(token, { now: NOW, timezone });

/** The UTC year `Date.parse` reads a resolved value as. */
const parsedYear = (value: unknown) => new Date(Date.parse(String(value))).getUTCFullYear();

const HOSTS = ['UTC', 'Asia/Shanghai'] as const;
const originalTz = process.env.TZ;
afterAll(() => {
if (originalTz === undefined) delete process.env.TZ;
else process.env.TZ = originalTz;
});

describe.each(HOSTS)('on a %s host', (host) => {
beforeEach(() => {
process.env.TZ = host;
expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(host);
});

describe('[#20844] a day outside 0001..9999 is spelled so the range reads the year it resolved to', () => {
it.each([
['8000_years_from_now', '+010026-09-30', 10026],
['7974_years_from_now', '+010000-09-30', 10000],
['2027_years_ago', '-000001-09-30', -1],
['2026_years_ago', '0000-09-30', 0],
['100000_months_from_now', '+010360-01-30', 10360],
])('{%s} is %s, year %i, outside both kinds\' years', (token, expected, year) => {
for (const tz of [undefined, 'UTC', 'Asia/Shanghai']) {
const day = at(token, tz);
expect(day, `${token} in ${tz ?? 'the default zone'}`).toBe(expected);
expect(parsedYear(day)).toBe(year);
expect(isOutsideTemporalYearRange(day, 'date')).toBe(true);
expect(isOutsideTemporalYearRange(day, 'datetime')).toBe(true);
}
});

it.each([
['7973_years_from_now', '9999-09-30', false],
['2025_years_ago', '0001-09-30', true],
['1026_years_ago', '1000-09-30', false],
['1027_years_ago', '0999-09-30', true],
['1_year_ago', '2025-09-30', false],
])('{%s} is %s: inside a date\'s years, and outside a datetime\'s: %s', (token, expected, outsideDatetime) => {
const day = at(token);
expect(day).toBe(expected);
expect(isOutsideTemporalYearRange(day, 'date')).toBe(false);
expect(isOutsideTemporalYearRange(day, 'datetime')).toBe(outsideDatetime);
});

it('a sub-day placeholder past 9999 keeps the instant toISOString spells', () => {
const instant = at('80000000_hours_from_now');
expect(instant).toBe(new Date(NOW.getTime() + 80_000_000 * 3_600_000).toISOString());
expect(String(instant).startsWith('+011153-')).toBe(true);
expect(isOutsideTemporalYearRange(instant, 'datetime')).toBe(true);
});

it('resolveFilterTokens carries the same spelling into a filter tree', () => {
expect(resolveFilterTokens({ opened_at: { $lt: '{2027_years_ago}' } }, { now: NOW })).toEqual({
opened_at: { $lt: '-000001-09-30' },
});
});
});
});
27 changes: 25 additions & 2 deletions packages/core/src/utils/filter-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@
* a driver-native value here would fork that convention into a second source of
* truth and break the moment a query crosses datasources.
*
* [#20844] A day outside the years 0001..9999 has no `YYYY-MM-DD` form; it is
* spelled in the expanded-year form instead (`+010026-10-01`), so every reader
* reads the year it resolved to — see {@link asYmd}. Which years a field takes
* is the engine's question, not this module's: it refuses a resolved token
* outside its field's years.
*
* # Period `_end` resolves to a calendar DAY — its WIDTH is ADR-0053 D-D
*
* `{current_year_end}` resolves to `2026-12-31`, per the spec's own
Expand Down Expand Up @@ -92,7 +98,7 @@ import {
type DateMacroUnit,
} from '@objectstack/spec/data';
import { calendarPartsInTzOrUtc, wallClockToUtcMs } from './datetime.js';
import { temporalStorageForm } from './temporal-storage-form.js';
import { isOutsideTemporalYearRange, temporalStorageForm } from './temporal-storage-form.js';

/**
* The slice of an execution context the resolver reads. Structural on purpose —
Expand Down Expand Up @@ -202,8 +208,25 @@ function proxyDay(now: Date, timezone?: string): Date {
* day). [#20599] Before the proxy dates kept their year, a step into
* 0001..0099 came out in the 1900s instead, so this spelling was never reached
* for those years; a step into 0100..0999 was already spelled unpadded.
*
* [#20844] A day outside 0001..9999 has no `YYYY-MM-DD` form, and the storage
* rule's spelling of one (`10026-10-01`, `0-10-01`, `-1-10-01`) is read by
* nothing as the day it names: `Date.parse` takes it through the host's
* legacy parser, in the host's zone, and reads `-1-10-01` as 2001-01-10. So
* `{2027_years_ago}` was judged inside the range by core's
* `isOutsideTemporalYearRange` and compared as a day in 2001. Such a day is
* spelled in the expanded-year form of ECMAScript's date time string format
* instead (`+010026-10-01`, `-000001-10-01`), the day half of what
* `toISOString` spells for its instant: every reader reads it as that UTC day,
* on every host. The range is core's one range, asked of the day itself, never
* re-derived here. A step past the instants a `Date` holds is not one of these:
* it has no day at all, and keeps the rule's spelling of an invalid `Date`.
*/
const asYmd = (d: Date): string => String(temporalStorageForm(d, 'date'));
function asYmd(d: Date): string {
if (!isOutsideTemporalYearRange(d, 'date')) return String(temporalStorageForm(d, 'date'));
const iso = d.toISOString();
return iso.slice(0, iso.indexOf('T'));
}

type PeriodKind = 'week' | 'month' | 'quarter' | 'year';

Expand Down
Loading
Loading