diff --git a/.changeset/type-blind-temporal-date-operands.md b/.changeset/type-blind-temporal-date-operands.md new file mode 100644 index 0000000000..a8583b4541 --- /dev/null +++ b/.changeset/type-blind-temporal-date-operands.md @@ -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. diff --git a/packages/core/src/utils/datetime.ts b/packages/core/src/utils/datetime.ts index c1ef541393..bced1cb7ab 100644 --- a/packages/core/src/utils/datetime.ts +++ b/packages/core/src/utils/datetime.ts @@ -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 diff --git a/packages/formula/src/matches-filter-temporal-conformance.test.ts b/packages/formula/src/matches-filter-temporal-conformance.test.ts index b97e15bc39..c04d21f5f7 100644 --- a/packages/formula/src/matches-filter-temporal-conformance.test.ts +++ b/packages/formula/src/matches-filter-temporal-conformance.test.ts @@ -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. * diff --git a/packages/formula/src/matches-filter.ts b/packages/formula/src/matches-filter.ts index 19851c2afc..78fad38984 100644 --- a/packages/formula/src/matches-filter.ts +++ b/packages/formula/src/matches-filter.ts @@ -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, filter: FilterCondition | null | undefined): boolean { @@ -70,15 +70,15 @@ function evalOp(actual: unknown, op: string, raw: unknown, record: Record (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); @@ -110,8 +110,43 @@ function evalOp(actual: unknown, op: string, raw: unknown, record: Record 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. */ 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 28cb73b35b..60a999e8b1 100644 --- a/packages/plugins/driver-memory/src/memory-temporal-conformance.test.ts +++ b/packages/plugins/driver-memory/src/memory-temporal-conformance.test.ts @@ -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'; @@ -30,6 +47,37 @@ import { InMemoryDriver } from './memory-driver.js'; const resolveTokens = (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; @@ -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()); + }); + } +}); 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 ae47801445..9b1660eeac 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 @@ -32,6 +32,29 @@ import { evaluateAnalyticsQueryOverRows, matchesWhere } from '../preview-evaluat const resolveTokens = (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, () => { @@ -39,6 +62,11 @@ describe('preview-evaluator — temporal conformance', () => { 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) { diff --git a/packages/services/service-analytics/src/preview-evaluator.ts b/packages/services/service-analytics/src/preview-evaluator.ts index 89c8e95848..cbe2c62df4 100644 --- a/packages/services/service-analytics/src/preview-evaluator.ts +++ b/packages/services/service-analytics/src/preview-evaluator.ts @@ -17,7 +17,7 @@ // 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'; @@ -25,8 +25,34 @@ type Row = Record; // ── 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; } diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 047ab404ec..cc1e129331 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -636,6 +636,7 @@ "sequenceWidth (function)", "stripLegacyApiMethods (function)", "suggestFieldType (function)", + "utcInstantMs (function)", "valueSchemaFor (function)" ], "./system": [ diff --git a/packages/spec/src/data/calendar-day.ts b/packages/spec/src/data/calendar-day.ts index fe0ad6083d..7776ed947b 100644 --- a/packages/spec/src/data/calendar-day.ts +++ b/packages/spec/src/data/calendar-day.ts @@ -52,6 +52,57 @@ export function nextUtcCalendarDay(value: unknown): string | null { return fmtUtcDay(new Date(Date.UTC(y, mo - 1, d + 1))); } +/** + * The UTC instant a temporal comparand denotes, in epoch milliseconds — or + * `null` when the value does not denote an instant at all. + * + * The companion of {@link nextUtcCalendarDay} for the OTHER half of the same + * problem. That one answers "how wide is this bound"; this one answers "can + * these two operands be ordered against each other at all", which is the + * question a **type-blind** evaluator faces when one side is a JS `Date` and + * the other is the platform's wire text. JS relational operators cannot: `<` + * and friends coerce both operands with hint `number`, so a `Date` becomes its + * epoch and an ISO string becomes `NaN`, and EVERY comparison between the two + * is false regardless of the instants involved. That is the same cross-type + * silence #4047 measured on the drivers, in the one place no schema is + * available to prevent it. + * + * Accepted spellings — all unambiguous, so the answer never depends on the + * process timezone: + * - a `Date` (and finite epoch-ms number); + * - a bare `YYYY-MM-DD` → that day's `00:00:00.000Z`, matching D-D's + * "a lower bound anchors to midnight" and ES's own date-only rule; + * - `YYYY-MM-DDTHH:MM:SS[.fff]` with an explicit `Z`/offset; + * - the zone-naive spelling of the same, whose wall clock **is** UTC — the + * platform-wide rule D-B2 states for `CURRENT_TIMESTAMP`-written rows. + * + * Everything else is `null`, deliberately: `Date.parse` would accept a + * zone-naive timestamp as LOCAL time (so the same data would compare + * differently under the temporal CI job's skewed zone) and, on V8, guesses at + * shapes no protocol defines. A bare wall clock (`'14:30:00'`) is `null` + * because it denotes no instant — a `Field.time` is not an instant (#2004), so + * a type-blind surface must leave those operands exactly as it found them + * rather than invent a calendar day for them. + */ +export function utcInstantMs(value: unknown): number | null { + if (value instanceof Date) { + const t = value.getTime(); + return Number.isNaN(t) ? null : t; + } + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + if (typeof value !== 'string') return null; + const s = value.trim(); + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) { + // Spec-defined as UTC; parse it through the calendar-day validator so an + // impossible day is refused here exactly as it is above. + return nextUtcCalendarDay(s) === null ? null : Date.parse(`${s}T00:00:00.000Z`); + } + const m = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)(Z|[+-]\d{2}:?\d{2})?$/.exec(s); + if (!m) return null; + const t = Date.parse(`${m[1]}T${m[2]}${m[3] ?? 'Z'}`); + return Number.isNaN(t) ? null : t; +} + /** `YYYY-MM-DD` of an instant's UTC calendar day. */ function fmtUtcDay(dt: Date): string { return ( diff --git a/packages/spec/src/data/temporal-conformance.ts b/packages/spec/src/data/temporal-conformance.ts index b2ac642566..2bae1acfe9 100644 --- a/packages/spec/src/data/temporal-conformance.ts +++ b/packages/spec/src/data/temporal-conformance.ts @@ -122,6 +122,36 @@ * sets, cell for cell, as the canonical sweep. Any cell that differs means * that backend's repair path has drifted from the consensus — the signal * #3773 / #3994 / #4047 all lacked. + * + * ## What the axis means on a TYPE-BLIND backend + * + * `formula`'s write-side `check` and the analytics preview evaluator have no + * storage to inject into — but they get the same cross-type pairing handed to + * them, and from real callers rather than from a fixture: + * + * - the RLS `check` post-image is the caller's RAW write payload (built in + * `plugin-security` before any driver `formatInput` runs), so an SDK write + * of `new Date()` arrives as a `Date` against wire-text comparands — and the + * mirror pairing arrives too, since a CEL `today()` lowers to a `Date`; + * - a preview row fetched from a mongo-backed dataset arrives as a BSON + * `Date` (D-E2), against the same wire-text comparands. + * + * So those consumers run the shared cases a second time over a `Date`-valued + * `at`, seeded by the same `writerForm` tag, and must reach the same row ids. + * Both diverged on 10 of the 16 cases when the axis was first pointed at them + * (#4191): JS relational operators coerce a `Date`/string pair to + * `epoch`/`NaN`, so `formula` denied writes fail-closed, while the preview — + * which falls back to `String(value)` — both dropped and ADMITTED rows, and a + * drafted chart's numbers changed at publish. `utcInstantMs` + * (`spec/data/calendar-day.ts`) is the shared primitive that fixed both, the + * same single-definition discipline D-D2 applied to `nextUtcCalendarDay`. + * + * The `date` column and the whole `Field.time` table stay out of that second + * sweep, by the scope rule this file already applies to `$gt`-on-`datetime`: a + * wall clock denotes no instant, so a type-blind surface must leave those + * operands exactly as it found them rather than invent a calendar day for + * them, and what a native `date` write denotes belongs with the backends that + * own a `date` storage form. */ import type { FilterCondition } from './filter.zod';