Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/temporal-conformance-token-axis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@objectstack/spec": minor
---

feat(spec): the relative-token axis completes the temporal conformance matrix (ADR-0053 D-A3.2)

`@objectstack/spec/data` gains `TEMPORAL_TOKEN_CASES` and `TEMPORAL_TOKEN_NOW`,
the last axis ADR-0053 D-A3 asked for. Where `TEMPORAL_CASES` takes already-
resolved comparands, these carry the filter **as authored** — placeholders
intact, `{ at: { $lte: '{today}' } }` — plus a pinned reference instant, and
each consumer resolves them through `resolveFilterTokens` exactly as the
ObjectQL engine does before calling a driver.

That asserts a property neither half could alone. `{today}` resolving to the
right calendar day is covered in core; a bare day as an upper bound meaning the
whole day is covered by the resolved-comparand cases. The **composition** was
never asserted — and the composition is the filter the default dashboard emits,
the one #3777 was reported against. `{current_month_end}` is included for the
same reason: the issue named it as the case where an author's "last day of the
month" intent had no layer translating it.

Run by the four backends downstream of resolution: `driver-sql` (and, through
the live-dialect CI job, real Postgres and MySQL), `driver-memory`,
`driver-mongodb` against a real MongoDB, and the analytics preview evaluator.
`formula`'s `matchesFilterCondition` is excluded on architecture rather than
coverage — an RLS `check` is CEL, where a relative date is the function
`today()` evaluated at compile time, so a `{token}` string cannot reach it.
33 changes: 31 additions & 2 deletions docs/adr/0053-date-and-datetime-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -876,7 +876,36 @@ Two things, which is the argument for having built it:

- The matrix is the ratchet the previous four fixes lacked: a backend that
drifts now fails a named case whose note says which incident it is repeating.
- Coverage still open, and deliberately so: the **relative-token** axis
- ~~Coverage still open, and deliberately so: the **relative-token** axis
(`{today}`, `{30_days_ago}` resolved through `filter-tokens.ts` and run
end-to-end per driver) is not yet in the table. The cases here take resolved
comparands, which is the layer where the four incidents actually happened.
comparands, which is the layer where the four incidents actually happened.~~
Landed 2026-07-30 — see D-A3.2.

### D-A3.2 — The relative-token axis closes the last gap in D-A3

`TEMPORAL_TOKEN_CASES` carries filters with their placeholders **intact**
(`{ at: { $lte: '{today}' } }`) plus a pinned reference instant, and each
consumer resolves them through `resolveFilterTokens` exactly as the ObjectQL
engine does before a driver is called.

This asserts a property neither half could on its own. That `{today}` resolves
to today's calendar day is covered in core; that a bare day as an upper bound
denotes the whole day is D-D. What was never asserted is the **composition** —
and the composition is the filter the default dashboard actually emits, the one
#3777 was reported against. `{current_month_end}` is in the table for the same
reason: the issue named it as the case where the author's "last day of the
month" intent had no layer translating it.

Run by the four backends downstream of resolution — the three drivers and the
analytics preview. `formula`'s `matchesFilterCondition` is excluded on
architecture, not for lack of coverage: an RLS `check` is a CEL expression
compiled by `compileCelToFilter`, where a relative date is the *function*
`today()` evaluated at compile time. A `{token}` string cannot reach it, since
`resolveFilterTokens` runs on the read path (`engine.ts`) and the write-side
check does not go through it. The exclusion is stated in that suite so it reads
as a decision rather than an omission.

With this, every axis D-A3 asked for is covered: `field-type × operator ×
relative-token × driver`, asserting row results, plus the `bound-semantics` and
`storage-form` axes the later addenda added.
16 changes: 16 additions & 0 deletions packages/formula/src/matches-filter-temporal-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,19 @@ describe('matchesFilterCondition — temporal conformance', () => {
});
}
});

/**
* The matrix's relative-token axis (`TEMPORAL_TOKEN_CASES`) is deliberately NOT
* run here, and the reason is architectural rather than a gap in coverage.
*
* This evaluator's filters come from `compileCelToFilter`: an RLS `check` is a
* CEL expression, where a relative date is the *function* `today()` evaluated
* during compilation. A `{token}` string never reaches it — `resolveFilterTokens`
* runs on the ObjectQL read path (`engine.ts`), which the write-side `check` does
* not go through.
*
* Asserting the axis here would therefore test a shape this backend cannot be
* handed; if that ever changes — if a `check` gains a token-bearing filter form —
* this comment is the thing to delete, and the axis is already sitting in spec
* ready to be consumed.
*/
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
*/

import { describe, it, expect, beforeAll } from 'vitest';
import { TEMPORAL_CASES, TEMPORAL_ROWS } from '@objectstack/spec/data';
import { TEMPORAL_CASES, TEMPORAL_ROWS, TEMPORAL_TOKEN_CASES } from '@objectstack/spec/data';
import { resolveFilterTokens } from '@objectstack/core';
import { InMemoryDriver } from './memory-driver.js';

describe('driver-memory — temporal conformance', () => {
Expand Down Expand Up @@ -39,3 +40,33 @@ describe('driver-memory — temporal conformance', () => {
});
}
});

/**
* The relative-token axis — the filter as authored, resolved here the way the
* ObjectQL engine resolves `ast.where` before a driver ever sees it. Asserts
* that token resolution and bound semantics compose.
*/
describe('driver-memory — temporal conformance (relative tokens)', () => {
let driver: InMemoryDriver;

beforeAll(async () => {
driver = new InMemoryDriver({});
await driver.connect();
await driver.syncSchema('conformance', {
name: 'conformance',
fields: { at: { type: 'datetime' }, on: { type: 'date' }, why: { type: 'string' } },
});
for (const r of TEMPORAL_ROWS) {
await driver.create('conformance', { id: r.id, at: r.at, on: r.on, why: r.why });
}
});

for (const c of TEMPORAL_TOKEN_CASES) {
it(c.name, async () => {
const where = resolveFilterTokens(c.filter, { now: new Date(c.now) });
const rows = await driver.find('conformance', { where } as any);
const got = (rows as any[]).map((r) => r.id).sort();
expect(got, c.note).toEqual([...c.expected].sort());
});
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { TEMPORAL_CASES, TEMPORAL_ROWS } from '@objectstack/spec/data';
import { TEMPORAL_CASES, TEMPORAL_ROWS, TEMPORAL_TOKEN_CASES } from '@objectstack/spec/data';
import { resolveFilterTokens } from '@objectstack/core';
import { MongoDBDriver } from './mongodb-driver.js';

let sharedMongod: MongoMemoryServer | undefined;
Expand Down Expand Up @@ -58,4 +59,15 @@ describe.skipIf(!sharedMongod)('driver-mongodb — temporal conformance', () =>
expect(got, c.note).toEqual([...c.expected].sort());
});
}

// The relative-token axis — resolved here the way the ObjectQL engine
// resolves `ast.where` before a driver sees it.
for (const c of TEMPORAL_TOKEN_CASES) {
it(c.name, async () => {
const where = resolveFilterTokens(c.filter, { now: new Date(c.now) });
const rows = await driver.find('conformance', { where } as any);
const got = (rows as any[]).map((r) => r.id).sort();
expect(got, c.note).toEqual([...c.expected].sort());
});
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { TEMPORAL_CASES, TEMPORAL_ROWS } from '@objectstack/spec/data';
import { TEMPORAL_CASES, TEMPORAL_ROWS, TEMPORAL_TOKEN_CASES } from '@objectstack/spec/data';
import { resolveFilterTokens } from '@objectstack/core';
import { SqlDriver } from '../src/index.js';

describe('sql-driver — temporal conformance', () => {
Expand Down Expand Up @@ -66,3 +67,52 @@ describe('sql-driver — temporal conformance', () => {
});
}
});

/**
* The relative-token axis: the same rows, but the filter still carries
* `{today}` / `{current_month_end}` when the case starts. Resolution is the
* ObjectQL engine's job (`engine.ts` resolves `ast.where` before the driver is
* called), so the suite performs it the same way, with the case's pinned
* instant, and then hands the driver exactly what the engine would.
*
* This asserts the composition the resolved-comparand cases cannot: that a
* token resolving correctly and a bare day meaning the whole day add up to the
* right rows for the filter an author actually wrote.
*/
describe('sql-driver — temporal conformance (relative tokens)', () => {
let driver: SqlDriver;

beforeAll(async () => {
driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
await driver.initObjects([
{
name: 'conformance',
fields: { at: { type: 'datetime' }, on: { type: 'date' }, why: { type: 'string' } },
},
]);
for (const r of TEMPORAL_ROWS) {
await driver.create(
'conformance',
{ id: r.id, at: r.at, on: r.on, why: r.why },
{ bypassTenantAudit: true } as any,
);
}
});

afterAll(async () => {
await driver.disconnect?.();
});

for (const c of TEMPORAL_TOKEN_CASES) {
it(c.name, async () => {
const where = resolveFilterTokens(c.filter, { now: new Date(c.now) });
const rows = await driver.find('conformance', { where } as any);
const got = (rows as any[]).map((r) => r.id).sort();
expect(got, c.note).toEqual([...c.expected].sort());
});
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
*/

import { describe, it, expect } from 'vitest';
import { TEMPORAL_CASES, TEMPORAL_ROWS } from '@objectstack/spec/data';
import { TEMPORAL_CASES, TEMPORAL_ROWS, TEMPORAL_TOKEN_CASES } from '@objectstack/spec/data';
import { resolveFilterTokens } from '@objectstack/core';
import { matchesWhere } from '../preview-evaluator.js';

describe('preview-evaluator — temporal conformance', () => {
Expand All @@ -29,3 +30,18 @@ describe('preview-evaluator — temporal conformance', () => {
});
}
});

/**
* The relative-token axis. A dashboard filter reaches the preview with its
* placeholders already resolved, so the suite resolves them the same way — with
* the case's pinned instant — and asserts the composed result.
*/
describe('preview-evaluator — temporal conformance (relative tokens)', () => {
for (const c of TEMPORAL_TOKEN_CASES) {
it(c.name, () => {
const where = resolveFilterTokens(c.filter, { now: new Date(c.now) });
const got = TEMPORAL_ROWS.filter((r) => matchesWhere(r as any, where as any)).map((r) => r.id);
expect(got, c.note).toEqual(c.expected);
});
}
});
3 changes: 3 additions & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -531,12 +531,15 @@
"StringOperatorSchema (const)",
"TEMPORAL_CASES (const)",
"TEMPORAL_ROWS (const)",
"TEMPORAL_TOKEN_CASES (const)",
"TEMPORAL_TOKEN_NOW (const)",
"TITLE_ELIGIBLE (const)",
"TITLE_ELIGIBLE_TYPES (const)",
"TITLE_INELIGIBLE_TYPES (const)",
"TemporalCase (interface)",
"TemporalFieldKind (type)",
"TemporalRow (interface)",
"TemporalTokenCase (interface)",
"TenancyConfig (type)",
"TenancyConfigSchema (const)",
"TimeUpdateInterval (const)",
Expand Down
97 changes: 97 additions & 0 deletions packages/spec/src/data/temporal-conformance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,3 +245,100 @@ export const TEMPORAL_CASES: readonly TemporalCase[] = [
note: 'Guards the opposite error: rolling 02-28 to 03-01 (skipping the 29th) would wrongly include h_leap.',
},
] as const;

// ── The relative-token axis ─────────────────────────────────────────────────

/**
* A case whose filter still carries `{token}` placeholders — the shape an
* author actually writes (ADR-0053 D-A3's `relative-token` axis).
*
* The cases above take *resolved* comparands, which is the layer the four
* incidents happened at. This axis asserts the layer above: that resolution and
* bound semantics **compose**. `{today}` resolving correctly and a bare day
* meaning the whole day are each covered on their own; what was never asserted
* is `$lte: '{today}'` end-to-end — which is precisely the filter the default
* dashboard emits, and the one #3777 was reported against.
*
* `now` is pinned so a case is a fixed expectation rather than a moving one.
* The resolver (`resolveFilterTokens` in `@objectstack/core`) takes it
* explicitly for the same reason it pins one instant per tree: a filter whose
* bounds resolved microseconds apart could straddle a boundary.
*
* # Which backends run this axis
*
* The four that sit downstream of resolution: the three drivers (the ObjectQL
* engine resolves `ast.where` before the driver sees it) and the analytics
* preview. `formula`'s `matchesFilterCondition` does **not** — its filters come
* from `compileCelToFilter`, where a relative date is the CEL *function*
* `today()` evaluated during compilation, never a `{token}` string. That is an
* architectural exclusion, not a coverage hole.
*/
export interface TemporalTokenCase {
/** Stable identifier, usable as a test name. */
name: string;
field: 'at' | 'on';
kind: TemporalFieldKind;
/** The filter as authored — placeholders intact. */
filter: FilterCondition;
/** Reference instant for resolution, as an ISO string. */
now: string;
/** Ids of matching rows, ascending, after resolution. */
expected: string[];
/** Why the case is here — surfaced in failure output. */
note?: string;
}

/**
* Pinned so `{today}` is the fixture's boundary day and `{current_month_*}`
* brackets it — the same 2026-07-28 the resolved-comparand cases use, at a time
* of day (09:15) that a midnight-anchored upper bound would discard.
*/
export const TEMPORAL_TOKEN_NOW = '2026-07-28T09:15:00.000Z';

export const TEMPORAL_TOKEN_CASES: readonly TemporalTokenCase[] = [
{
name: 'token: `$lte: {today}` on datetime keeps all of today',
field: 'at',
kind: 'datetime',
filter: { at: { $lte: '{today}' } },
now: TEMPORAL_TOKEN_NOW,
expected: ['a_old', 'b_prev', 'c_open', 'd_mid', 'e_late', 'h_leap'],
note: '#3777 as authored: the dashboard emits this, and pre-fix it dropped d_mid and e_late — rows created earlier the same day.',
},
{
name: 'token: `$lte: {today}` on date behaves identically',
field: 'on',
kind: 'date',
filter: { on: { $lte: '{today}' } },
now: TEMPORAL_TOKEN_NOW,
expected: ['a_old', 'b_prev', 'c_open', 'd_mid', 'e_late', 'h_leap'],
note: 'The two field types must not diverge once the token is resolved.',
},
{
name: 'token: a `{current_month_start}`..`{current_month_end}` window spans the whole final day',
field: 'at',
kind: 'datetime',
filter: { at: { $gte: '{current_month_start}', $lte: '{current_month_end}' } },
now: TEMPORAL_TOKEN_NOW,
expected: ['b_prev', 'c_open', 'd_mid', 'e_late', 'f_next', 'g_eom'],
note: 'The issue named this one: `{current_month_end}` means "the last DAY of the month", so g_eom at 23:59:59.999 on the 31st has to be inside.',
},
{
name: 'token: `{30_days_ago}`..`{today}` is the rolling-window shape',
field: 'at',
kind: 'datetime',
filter: { at: { $gte: '{30_days_ago}', $lte: '{today}' } },
now: TEMPORAL_TOKEN_NOW,
expected: ['b_prev', 'c_open', 'd_mid', 'e_late'],
note: 'The lower bound stays midnight-anchored while the upper spans its day — the two halves of the rule in one filter.',
},
{
name: 'token: a parameterised `{1_days_ago}` upper bound stops before today',
field: 'at',
kind: 'datetime',
filter: { at: { $lte: '{1_days_ago}' } },
now: TEMPORAL_TOKEN_NOW,
expected: ['a_old', 'b_prev', 'h_leap'],
note: 'Guards the off-by-one in the other direction: widening must land on the 28th, not the 29th.',
},
] as const;
Loading