Skip to content
Merged
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
33 changes: 33 additions & 0 deletions .changeset/type-blind-temporal-date-operands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
'@objectstack/spec': minor
'@objectstack/core': minor
'@objectstack/formula': patch
'@objectstack/service-analytics': patch
---

Order temporal operands correctly when one side is a JS `Date` on the two
type-blind filter backends (ADR-0053 D-A3 / #4191).

`utcInstantMs` joins `nextUtcCalendarDay` in `@objectstack/spec/data`
(re-exported from `@objectstack/core`): it reads the UTC instant a temporal
operand denotes, accepting only unambiguous spellings — a `Date`, epoch ms, a
bare `YYYY-MM-DD`, and an ISO timestamp with or without an explicit zone (a
zone-naive one being UTC, per D-B2) — and returning `null` for everything
else, notably a bare wall clock, which denotes no instant.

Both type-blind evaluators now use it to compare a `Date` against wire text,
which JS relational operators cannot do: `<` and friends coerce with hint
`number`, so the `Date` becomes its epoch and the string becomes `NaN`.

- `formula`'s `matchesFilterCondition` (the RLS write-side `check`) dropped
every `Date`-valued row in 10 of the 16 shared conformance cases. The
post-image is the caller's raw write payload, so an SDK write of
`new Date()` hit this directly, and fail-closed turned it into a **denied
write**.
- `service-analytics`' preview evaluator diverged on the same 10 cases in
BOTH directions, because `String(new Date())` sorts after every `'2026-…'`
comparand — a drafted chart both lost rows and gained ones, then changed
its numbers at publish. Rows from a mongo-backed dataset arrive as BSON
`Date`s, so this was reachable in normal use.

Comparisons that did not involve a `Date` are unchanged.
2 changes: 1 addition & 1 deletion packages/core/src/utils/datetime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export function zonedDateStartToUtcMs(ymd: string, tz?: string): number {
* Re-exported here so the published `@objectstack/core` surface is unchanged
* for the drivers and analytics strategies that already import it from here.
*/
export { nextUtcCalendarDay } from '@objectstack/spec/data';
export { nextUtcCalendarDay, utcInstantMs } from '@objectstack/spec/data';

/**
* Granularity of a canonical date-bucket key. Mirrors `@objectstack/spec`'s
Expand Down
46 changes: 46 additions & 0 deletions packages/formula/src/matches-filter-temporal-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,52 @@ describe('matchesFilterCondition — temporal conformance', () => {
}
});

/**
* The storage-form axis, as it exists on a type-blind surface (#4191).
*
* The drivers' version of this axis injects rows below the write path. This
* evaluator has no storage to inject into — but it has the same defect for the
* same reason, and the population is not synthetic: `plugin-security` builds
* the RLS `check` post-image as `{ ...opCtx.data }`, the caller's RAW write
* payload, BEFORE any driver `formatInput` converges it. So an SDK write of
* `new Date()` is exactly what this evaluator is handed, and the shared
* `writerForm` tag names precisely that population.
*
* Measured before the fix: 10 of the 16 shared cases dropped every
* `Date`-valued row, because JS relational operators coerce a `Date`/ISO-string
* pair to `epoch`/`NaN`. Fail-closed turns that into a **denied write** — the
* write-side twin of #4047, on the surface where the failure direction is a
* rejected write rather than a missing row (the same asymmetry D-D2 recorded
* for the bare-day upper bound).
*
* `on` stays text: a `Field.date` payload is a calendar day, and the shared
* expectations for the `date` column are calendar-day text semantics. A `Date`
* there would be asserting a different question (what a native `date` write
* denotes), which belongs with the drivers that own a `date` storage form.
*/
describe('matchesFilterCondition — temporal conformance on a native-writer post-image', () => {
const nativeRows = TEMPORAL_ROWS.map((r) => ({
...r,
at: r.writerForm === 'native' ? new Date(r.at) : r.at,
}));

for (const c of TEMPORAL_CASES) {
it(c.name, () => {
const got = nativeRows.filter((r) => matchesFilterCondition(r as any, c.filter)).map((r) => r.id);
expect(got, c.note).toEqual(c.expected);
});
}

it('the mirror pairing too: a CEL-lowered Date comparand against wire-form records', () => {
// `today()` lowers to a `Date` at UTC midnight (ADR-0053 D1), so a compiled
// `check` hands this evaluator the OTHER cross-type pairing. It broke
// identically and must answer identically.
const bound = new Date('2026-07-28T00:00:00.000Z');
const got = TEMPORAL_ROWS.filter((r) => matchesFilterCondition(r, { at: { $gte: bound } } as any)).map((r) => r.id);
expect(got).toEqual(['c_open', 'd_mid', 'e_late', 'f_next', 'g_eom']);
});
});

/**
* Why the token axis is absent here.
*
Expand Down
49 changes: 42 additions & 7 deletions packages/formula/src/matches-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
*/

import type { FilterCondition } from '@objectstack/spec/data';
import { nextUtcCalendarDay } from '@objectstack/spec/data';
import { nextUtcCalendarDay, utcInstantMs } from '@objectstack/spec/data';

/** True iff `record` satisfies `filter`. A null/empty filter matches everything. */
export function matchesFilterCondition(record: Record<string, unknown>, filter: FilterCondition | null | undefined): boolean {
Expand Down Expand Up @@ -70,15 +70,15 @@ function evalOp(actual: unknown, op: string, raw: unknown, record: Record<string
switch (op) {
case '$eq': return v === null ? actual == null : looseEq(actual, v);
case '$ne': return v === null ? actual != null : !looseEq(actual, v);
case '$gt': return actual != null && v != null && (actual as never) > (v as never);
case '$gte': return actual != null && v != null && (actual as never) >= (v as never);
case '$lt': return actual != null && v != null && (actual as never) < (v as never);
case '$gt': return actual != null && v != null && order(actual, v, (a, b) => a > b);
case '$gte': return actual != null && v != null && order(actual, v, (a, b) => a >= b);
case '$lt': return actual != null && v != null && order(actual, v, (a, b) => a < b);
case '$lte': return actual != null && v != null && lteBound(actual, v);
case '$in': return Array.isArray(v) && v.some((x) => looseEq(actual, x));
case '$nin': return Array.isArray(v) && !v.some((x) => looseEq(actual, x));
case '$between':
return Array.isArray(v) && v.length === 2 && actual != null && v[0] != null && v[1] != null
&& (actual as never) >= (v[0] as never) && lteBound(actual, v[1]);
&& order(actual, v[0], (a, b) => a >= b) && lteBound(actual, v[1]);
case '$contains': return typeof actual === 'string' && typeof v === 'string' && actual.includes(v);
case '$notContains': return !(typeof actual === 'string' && typeof v === 'string' && actual.includes(v));
case '$startsWith': return typeof actual === 'string' && typeof v === 'string' && actual.startsWith(v);
Expand Down Expand Up @@ -110,8 +110,43 @@ function evalOp(actual: unknown, op: string, raw: unknown, record: Record<string
function lteBound(actual: unknown, bound: unknown): boolean {
if (bound == null) return false;
const nextDay = nextUtcCalendarDay(bound);
if (nextDay != null) return (actual as never) < (nextDay as never);
return (actual as never) <= (bound as never);
if (nextDay != null) return order(actual, nextDay, (a, b) => a < b);
return order(actual, bound, (a, b) => a <= b);
}

/**
* Apply an ordering comparison, lifting the pair to instants when exactly one
* side is a JS `Date` — the cross-type case JS relational operators answer
* `false` to unconditionally (they coerce with hint `number`, so the `Date`
* becomes its epoch and the ISO string becomes `NaN`).
*
* This is not a hypothetical pairing on this surface: the RLS `check`
* post-image is the caller's RAW write payload (`{ ...opCtx.data }` in
* `plugin-security`, built before any driver `formatInput` converges it), so an
* SDK write of `new Date()` lands here as a `Date` while the policy's comparand
* is the platform's wire text — and the mirror pairing arrives too, because a
* CEL `today()` lowers to a `Date` against a record holding canonical text.
* Measured against the shared matrix, 10 of 16 cases dropped every
* `Date`-valued row; fail-closed makes that a **denied write**, the write-side
* twin of #4047's missing rows and the same failure direction D-D2 recorded
* for the bare-day upper bound.
*
* Deliberately narrow. The lift triggers only when one operand is a `Date`
* AND {@link utcInstantMs} can read both as instants, so every comparison that
* worked before is byte-identical: string-vs-string keeps ISO lexicographic
* ordering, number-vs-number stays numeric, and a `Field.time` wall clock —
* which denotes no instant — is left alone rather than being given an invented
* calendar day. Anything that cannot be lifted falls through to the original
* comparison, so the security posture never becomes more permissive than the
* operands actually justify.
*/
function order(actual: unknown, bound: unknown, cmp: (a: never, b: never) => boolean): boolean {
if (actual instanceof Date || bound instanceof Date) {
const a = utcInstantMs(actual);
const b = utcInstantMs(bound);
if (a !== null && b !== null) return cmp(a as never, b as never);
}
return cmp(actual as never, bound as never);
}

/** Resolve a `{ $field: 'path' }` reference against the record; else passthrough. */
Expand Down
116 changes: 116 additions & 0 deletions packages/plugins/driver-memory/src/memory-temporal-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,23 @@
* the sweep proves the converged storage answers every shared case. Cases
* carrying a token spelling also run through `resolveFilterTokens` at the
* pinned `TEMPORAL_NOW` — the D-A3 "token → row results" axis (#4081).
*
* The last two describes add the **storage-form axis** (#4191): rows that
* entered the table BEFORE any schema was declared, in the raw pre-#4047
* forms, and were therefore never touched by the write path. That is not a
* hypothetical population here — it is precisely what D-E3 says this driver
* has to converge:
*
* > `driver-memory` converges the rows already in a table when the schema
* > arrives, which is what catches `initialData` fixtures and
* > persistence-adapter restores (both land before any schema is declared).
*
* `initialData` is the named case and the one seeded here: `connect()` pushes
* the fixtures straight into the table, `syncSchema` then runs the retroactive
* pass. The claim under test is that the pass leaves the table answering the
* SAME row-id sets as the canonical sweep — the in-memory analogue of the
* `backfillCanonicalDatetimes` sweeps in `driver-sql`, and the half of D-E3
* that had no coverage at all.
*/

import { describe, it, expect, beforeAll } from 'vitest';
Expand All @@ -30,6 +47,37 @@ import { InMemoryDriver } from './memory-driver.js';
const resolveTokens = <T,>(filter: T): T =>
resolveFilterTokens(filter, { now: new Date(TEMPORAL_NOW) });

/**
* The pre-#4047 storage forms, per the shared axis's seeding basis: `native`
* → the raw platform-native instant (a JS `Date`, what an SDK caller or this
* driver's own timestamp defaults produced), `wire` → raw text the write path
* never rewrote (the zone-naive `YYYY-MM-DD HH:MM:SS` a `CURRENT_TIMESTAMP`
* default or a restored snapshot carries). Neither is this driver's canon —
* canonical UTC ISO text for `datetime`, bare-day text for `date` (D-E2) — so
* the fixture is genuinely un-converged on both halves rather than only one.
*/
const legacyRows = () =>
TEMPORAL_ROWS.map((r) => ({
id: r.id,
at: r.writerForm === 'native' ? new Date(r.at) : r.at.replace('T', ' ').replace('Z', ''),
on: r.writerForm === 'native' ? new Date(r.on) : r.on,
why: r.why,
}));

/**
* The `Field.time` twin: `native` → a `Date` whose UTC time-of-day is the wall
* clock (`a_midnight` becomes the epoch instant itself — the row whose
* cross-type comparison hid it from every text bound), `wire` → a full
* timestamp still carrying a calendar day, pinned to the fixture's boundary
* day so every consumer of this axis seeds the same bytes.
*/
const legacyTimeRows = () =>
TEMPORAL_TIME_ROWS.map((r) => ({
id: r.id,
at: r.writerForm === 'native' ? new Date(`1970-01-01T${r.at}Z`) : `2026-07-28T${r.at}Z`,
why: r.why,
}));

describe('driver-memory — temporal conformance', () => {
let driver: InMemoryDriver;

Expand Down Expand Up @@ -99,3 +147,71 @@ describe('driver-memory — Field.time conformance', () => {
});
}
});

describe('driver-memory — temporal conformance on rows that predate the schema (D-E3)', () => {
let driver: InMemoryDriver;

beforeAll(async () => {
// `initialData` lands during connect(), BEFORE any schema is declared, so
// nothing coerced these values — the population D-E3 promises to converge.
driver = new InMemoryDriver({ initialData: { conformance: legacyRows() } });
await driver.connect();
// The retroactive pass runs here, on rows this driver never wrote.
await driver.syncSchema('conformance', {
name: 'conformance',
fields: { at: { type: 'datetime' }, on: { type: 'date' }, why: { type: 'string' } },
});
});

it('converged every pre-schema row to the storage canon (the premise, so the sweep cannot pass vacuously)', async () => {
const rows = await driver.find('conformance', {} as any);
expect(rows).toHaveLength(TEMPORAL_ROWS.length);
for (const row of rows as any[]) {
const expected = TEMPORAL_ROWS.find((r) => r.id === row.id)!;
// Canonical UTC ISO text for `datetime`, bare-day text for `date`
// (D-E2) — no `Date` object and no zone-naive spelling survives.
expect(row.at, `${row.id}.at`).toBe(expected.at);
expect(row.on, `${row.id}.on`).toBe(expected.on);
}
});

// Literal spellings only: the token axis is orthogonal to storage form and
// already swept above — a divergence here is a convergence bug by construction.
for (const c of TEMPORAL_CASES) {
it(c.name, async () => {
const rows = await driver.find('conformance', { where: c.filter } as any);
const got = (rows as any[]).map((r) => r.id).sort();
expect(got, c.note).toEqual([...c.expected].sort());
});
}
});

describe('driver-memory — Field.time conformance on rows that predate the schema (D-E3)', () => {
let driver: InMemoryDriver;

beforeAll(async () => {
driver = new InMemoryDriver({ initialData: { time_conformance: legacyTimeRows() } });
await driver.connect();
await driver.syncSchema('time_conformance', {
name: 'time_conformance',
fields: { at: { type: 'time' }, why: { type: 'string' } },
});
});

it('converged every pre-schema wall clock to the storage canon (the premise)', async () => {
const rows = await driver.find('time_conformance', {} as any);
expect(rows).toHaveLength(TEMPORAL_TIME_ROWS.length);
for (const row of rows as any[]) {
const expected = TEMPORAL_TIME_ROWS.find((r) => r.id === row.id)!;
expect(row.at, `${row.id}.at`).toBe(expected.at);
}
});

for (const c of TEMPORAL_TIME_CASES) {
it(c.name, async () => {
const rows = await driver.find('time_conformance', { where: c.filter } 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 @@ -32,13 +32,41 @@ import { evaluateAnalyticsQueryOverRows, matchesWhere } from '../preview-evaluat
const resolveTokens = <T,>(filter: T): T =>
resolveFilterTokens(filter, { now: new Date(TEMPORAL_NOW) });

/**
* The storage-form axis on a type-blind surface (#4191).
*
* There is no storage to inject into here, but the same cross-type pairing
* arrives all the same, and not synthetically: `Field.datetime`'s storage form
* is a BSON `Date` on `driver-mongodb` (D-E2), so rows fetched from a
* mongo-backed dataset reach this evaluator as `Date` objects while the
* comparands stay wire text. The shared `writerForm` tag names exactly that
* population.
*
* Measured before the fix: 10 of the 16 shared cases diverged — and in BOTH
* directions, which is what makes this surface's variant nastier than the
* drivers'. `String(new Date())` is `'Mon Jul 27 2026 …'`, which sorts after
* every `'2026-…'` comparand, so a window dropped rows that belong in it and
* admitted rows that do not. A drafted chart therefore showed numbers that
* changed at publish — precisely the continuity this evaluator exists to
* provide.
*/
const nativeRows = TEMPORAL_ROWS.map((r) => ({
...r,
at: r.writerForm === 'native' ? new Date(r.at) : r.at,
}));

describe('preview-evaluator — temporal conformance', () => {
for (const c of TEMPORAL_CASES) {
it(c.name, () => {
const got = TEMPORAL_ROWS.filter((r) => matchesWhere(r as any, c.filter as any)).map((r) => r.id);
expect(got, c.note).toEqual(c.expected);
});

it(`${c.name} — on a native-writer (BSON Date) row population`, () => {
const got = nativeRows.filter((r) => matchesWhere(r as any, c.filter as any)).map((r) => r.id);
expect(got, c.note).toEqual(c.expected);
});

// The D-A3 token axis (#4081): the same case spelled in relative tokens,
// resolved at the pinned instant, must reach the same rows.
if (c.tokenFilter) {
Expand Down
28 changes: 27 additions & 1 deletion packages/services/service-analytics/src/preview-evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,42 @@
// Anything beyond (joins via `include`, raw SQL) falls back to the caller's
// normal execution path — the preview simply doesn't claim it.

import { calendarPartsInTzOrUtc, nextUtcCalendarDay } from '@objectstack/core';
import { calendarPartsInTzOrUtc, nextUtcCalendarDay, utcInstantMs } from '@objectstack/core';
import type { AnalyticsQuery, AnalyticsResult } from '@objectstack/spec/contracts';
import type { Cube } from '@objectstack/spec/data';

type Row = Record<string, unknown>;

// ── Filters (the unified Query DSL subset) ──────────────────────────────────

/**
* Order two operands the way every other filter backend orders them.
*
* The `Date` arm is load-bearing rather than defensive: `String(new Date())`
* is `'Mon Jul 27 2026 …'`, which under the plain string ordering below sorts
* AFTER every `'2026-…'` comparand — so a preview row carrying an instant both
* disappeared from windows it belongs in and appeared in ones it does not.
* Measured against the shared matrix, 10 of 16 cases diverged, and unlike the
* cross-type silence on the drivers this direction ADDS rows: a drafted chart
* showed numbers no published chart would.
*
* The population is real. `Field.datetime`'s storage form is a BSON `Date` on
* `driver-mongodb` (ADR-0053 D-E2), so rows fetched from a mongo-backed dataset
* arrive here as `Date` objects, while the comparands are wire text.
* {@link utcInstantMs} is the same primitive `formula`'s write-side evaluator
* uses for the same pairing, so the two type-blind surfaces cannot drift.
*
* Deliberately narrow: the lift runs only when one side is a `Date` and both
* read as instants, so string-vs-string keeps ISO lexicographic ordering and a
* `Field.time` wall clock — which denotes no instant — is left untouched.
*/
function compare(a: unknown, b: unknown): number {
if (typeof a === 'number' && typeof b === 'number') return a - b;
if (a instanceof Date || b instanceof Date) {
const ai = utcInstantMs(a);
const bi = utcInstantMs(b);
if (ai !== null && bi !== null) return ai - bi;
}
return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
}

Expand Down
1 change: 1 addition & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,7 @@
"sequenceWidth (function)",
"stripLegacyApiMethods (function)",
"suggestFieldType (function)",
"utcInstantMs (function)",
"valueSchemaFor (function)"
],
"./system": [
Expand Down
Loading
Loading