diff --git a/.changeset/temporal-conformance-token-axis.md b/.changeset/temporal-conformance-token-axis.md new file mode 100644 index 0000000000..a97f4a2436 --- /dev/null +++ b/.changeset/temporal-conformance-token-axis.md @@ -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. diff --git a/docs/adr/0053-date-and-datetime-semantics.md b/docs/adr/0053-date-and-datetime-semantics.md index 510b5420b5..1112a7e357 100644 --- a/docs/adr/0053-date-and-datetime-semantics.md +++ b/docs/adr/0053-date-and-datetime-semantics.md @@ -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. diff --git a/packages/formula/src/matches-filter-temporal-conformance.test.ts b/packages/formula/src/matches-filter-temporal-conformance.test.ts index a4d4e3fe8b..280e1edf73 100644 --- a/packages/formula/src/matches-filter-temporal-conformance.test.ts +++ b/packages/formula/src/matches-filter-temporal-conformance.test.ts @@ -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. + */ diff --git a/packages/plugins/driver-memory/src/memory-temporal-conformance.test.ts b/packages/plugins/driver-memory/src/memory-temporal-conformance.test.ts index b2255b461a..8ecc771e9e 100644 --- a/packages/plugins/driver-memory/src/memory-temporal-conformance.test.ts +++ b/packages/plugins/driver-memory/src/memory-temporal-conformance.test.ts @@ -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', () => { @@ -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()); + }); + } +}); diff --git a/packages/plugins/driver-mongodb/src/mongodb-temporal-conformance.test.ts b/packages/plugins/driver-mongodb/src/mongodb-temporal-conformance.test.ts index 3b40b7eeb2..ef9a15e9d1 100644 --- a/packages/plugins/driver-mongodb/src/mongodb-temporal-conformance.test.ts +++ b/packages/plugins/driver-mongodb/src/mongodb-temporal-conformance.test.ts @@ -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; @@ -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()); + }); + } }); diff --git a/packages/plugins/driver-sql/src/sql-driver-temporal-conformance.test.ts b/packages/plugins/driver-sql/src/sql-driver-temporal-conformance.test.ts index 958275a8dd..db4b44aa50 100644 --- a/packages/plugins/driver-sql/src/sql-driver-temporal-conformance.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-temporal-conformance.test.ts @@ -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', () => { @@ -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()); + }); + } +}); diff --git a/packages/services/service-analytics/src/__tests__/preview-temporal-conformance.test.ts b/packages/services/service-analytics/src/__tests__/preview-temporal-conformance.test.ts index 5964fc22b5..e160faf54f 100644 --- a/packages/services/service-analytics/src/__tests__/preview-temporal-conformance.test.ts +++ b/packages/services/service-analytics/src/__tests__/preview-temporal-conformance.test.ts @@ -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', () => { @@ -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); + }); + } +}); diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index d0e8371dd5..a61e816a92 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -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)", diff --git a/packages/spec/src/data/temporal-conformance.ts b/packages/spec/src/data/temporal-conformance.ts index d00c76670b..62bfe81746 100644 --- a/packages/spec/src/data/temporal-conformance.ts +++ b/packages/spec/src/data/temporal-conformance.ts @@ -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;