From 1a9e41b0f0f510145911a17556181145b44d559b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 09:29:45 +0000 Subject: [PATCH 1/2] feat(spec,service-analytics): a shared temporal conformance matrix (ADR-0053 D-A3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last decision in ADR-0053 that had never been actioned, and the regression backstop the four previous incidents lacked. #3650 (window dropped), #3773 (buckets collapsed to NULL), #3777 (bare-day upper bound dropped the final day), #4047 (cross-type comparison matched nothing) were each found by a human by accident, and each left a suite proving only ITS OWN issue against its own fixture. That is coverage without a standard: nothing could fail when two backends drifted apart. `spec/data/temporal-conformance.ts` exports TEMPORAL_ROWS (times of day chosen so a midnight-anchored bound and a whole-day bound cannot return the same set) and TEMPORAL_CASES (filter + expected row ids + a note naming the incident each case defends). Five backends consume it and assert ROW RESULTS through their real entry points — the ADR's original requirement: - driver-sql (typed; the live-dialect CI job also runs it against real Postgres and MySQL under a skewed process zone, no workflow change) - driver-memory, driver-mongodb (real MongoDB via mongodb-memory-server) - service-analytics preview evaluator, formula matchesFilterCondition Modelled on filter-logic-conformance.ts, which exists for the same reason one layer down (#3774); in spec for the D-D2 reason — every backend already depends on it, so this adds no dependency edge. The matrix found two things on its first run, which is the argument for having built it: 1. A real defect. The preview evaluator had no $between case, so it fell through to its permissive `default: return true` and matched EVERY row — a drafted dashboard with a range filter charted the whole dataset, then changed at publish, which is the exact continuity the preview exists to provide. Fixed here, sharing the upper-bound helper with $lte so the whole-day rule covers a range's max too. 2. A measured, irreducible limit. $gt with a bare-day comparand on a datetime column cannot agree across backends: a typed one anchors to midnight and excludes a 00:00 value, a type-blind one compares raw strings where '…T00:00…' sorts after the bare day and keeps it. The upper-bound trick has no lower-bound analogue — anchoring would fix datetime and break date. So $gt is shared on `date` only and the datetime cell stays in the typed drivers' suites; $gte IS shared, and is pinned precisely because the two readings agree by luck of the boundary being inclusive. Also: AGENTS.md gains the Post-Task step for regenerating the spec API-surface snapshot — OS_SKIP_DTS=1 skips the built declarations that gate reads, so it passes locally and fails in CI, which is how #4076 went red. spec 6957 / formula 286 / driver-sql 535 / driver-memory 207 / driver-mongodb 120 / service-analytics 342, all green. Closes #4081 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkL3RGJrzjLEGURsLxfS2f --- .changeset/temporal-conformance-matrix.md | 33 +++ AGENTS.md | 5 +- docs/adr/0053-date-and-datetime-semantics.md | 65 +++++ ...atches-filter-temporal-conformance.test.ts | 29 ++ .../src/memory-temporal-conformance.test.ts | 41 +++ .../src/mongodb-temporal-conformance.test.ts | 61 +++++ .../sql-driver-temporal-conformance.test.ts | 68 +++++ .../preview-temporal-conformance.test.ts | 31 +++ .../src/preview-evaluator.ts | 29 +- packages/spec/api-surface.json | 5 + packages/spec/src/data/index.ts | 1 + .../spec/src/data/temporal-conformance.ts | 247 ++++++++++++++++++ 12 files changed, 610 insertions(+), 5 deletions(-) create mode 100644 .changeset/temporal-conformance-matrix.md create mode 100644 packages/formula/src/matches-filter-temporal-conformance.test.ts create mode 100644 packages/plugins/driver-memory/src/memory-temporal-conformance.test.ts create mode 100644 packages/plugins/driver-mongodb/src/mongodb-temporal-conformance.test.ts create mode 100644 packages/plugins/driver-sql/src/sql-driver-temporal-conformance.test.ts create mode 100644 packages/services/service-analytics/src/__tests__/preview-temporal-conformance.test.ts create mode 100644 packages/spec/src/data/temporal-conformance.ts diff --git a/.changeset/temporal-conformance-matrix.md b/.changeset/temporal-conformance-matrix.md new file mode 100644 index 0000000000..97ed41b038 --- /dev/null +++ b/.changeset/temporal-conformance-matrix.md @@ -0,0 +1,33 @@ +--- +"@objectstack/spec": minor +"@objectstack/service-analytics": patch +--- + +feat(spec): a shared temporal conformance matrix, and the `$between` gap it found (ADR-0053 D-A3, #4081) + +`@objectstack/spec/data` gains `TEMPORAL_ROWS` and `TEMPORAL_CASES` — the +single set of temporal filter cases every backend is checked against, the twin +of the existing `FILTER_LOGIC_CASES`. Five backends consume it and assert **row +results**: `driver-sql` (and, through the live-dialect CI job, real Postgres and +MySQL), `driver-memory`, `driver-mongodb` (real MongoDB), the analytics preview +evaluator, and `formula`'s RLS write-side `check`. + +This is the regression backstop ADR-0053 D-A3 has asked for since 2026-06 and +the last of its decisions to be actioned. Four separate incidents — #3650, +#3773, #3777, #4047 — were each found by a human by accident, and each left a +suite proving only its own issue against its own fixture. Nothing held the +backends to one standard, so the fifth divergence had nowhere to fail. + +**`service-analytics` — a real fix the matrix found on its first run.** The +draft-preview evaluator had no `$between` case, so it fell through to its +permissive `default` and matched **every** row: a drafted dashboard carrying a +range filter charted the entire dataset, then changed its numbers at publish — +the exact continuity the preview exists to provide. It now evaluates +`$between`, sharing the upper-bound helper with `$lte` so the whole-day +calendar-day rule (#3777) applies to a range's max as well. + +Also recorded (ADR-0053 D-A3.1): `$gt` with a bare-day comparand on a +`datetime` column cannot agree between typed and type-blind backends, and the +gap is irreducible without field types. It is asserted in the shared matrix on +`date` only, with the `datetime` cell left to the typed drivers' own suites, +rather than papered over. diff --git a/AGENTS.md b/AGENTS.md index 1f96370329..30bec24afa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -312,8 +312,9 @@ export default { working tree. 3. **Add a changeset for feature work.** When the change is a feature or functional improvement, run `pnpm changeset` (or add a `.changeset/*.md` entry) describing it before committing. Pure bug fixes do **not** require a changeset. **Breaking changesets must carry their migration.** If the change removes or renames anything an author can write (a spec key, an export, a config field), the changeset body must state the FROM → TO mapping and the one-line fix — this text ships to consumers as `CHANGELOG.md` inside the npm package and is what an upgrading agent greps after the tombstone error. Removing an authorable spec key also requires a tombstone so the rejection itself carries the prescription — `retiredKey()` (`packages/spec/src/shared/retired-key.ts`) on a non-strict schema, or an entry in the relevant `UNKNOWN_KEY_GUIDANCE` / `*_RETIRED_KEY_GUIDANCE` map (see `object.zod.ts`, `ai/tool.zod.ts`) when the schema is `.strict()`. The changeset is one of fourteen surfaces a retirement touches — follow the `spec-property-retirement` skill (`.claude/skills/`) rather than reconstructing the kit, and note the two routes imply **opposite** liveness-ledger dispositions. -4. Update `CHANGELOG.md` / `ROADMAP.md` if user-facing or architectural. -5. **Delete temporary artifacts** — screenshots, traces, scratch logs, `.playwright-mcp/`, throwaway `tmp*.ts`, ad-hoc scripts. Repo must look identical to before, minus intended changes. +4. **Added or removed a `packages/spec` export? Run `pnpm --filter @objectstack/spec gen:api-surface` and commit the result.** The `TypeScript Type Check` job diffs spec's built export surface against `api-surface.json`; a new export makes the snapshot stale and turns the job red. It reads the **built `dist` declarations**, so `OS_SKIP_DTS=1` — the flag you reach for to make local builds fast — skips exactly the artifact the gate inspects, and the check passes locally while failing in CI. Same shape for the other generated-artifact gates in that job (`check:docs`, `check:skill-refs`, `check:react-blocks`), which read `src/` and so do reproduce locally. +5. Update `CHANGELOG.md` / `ROADMAP.md` if user-facing or architectural. +6. **Delete temporary artifacts** — screenshots, traces, scratch logs, `.playwright-mcp/`, throwaway `tmp*.ts`, ad-hoc scripts. Repo must look identical to before, minus intended changes. --- diff --git a/docs/adr/0053-date-and-datetime-semantics.md b/docs/adr/0053-date-and-datetime-semantics.md index e1d29991da..510b5420b5 100644 --- a/docs/adr/0053-date-and-datetime-semantics.md +++ b/docs/adr/0053-date-and-datetime-semantics.md @@ -401,6 +401,9 @@ retire it)** once the contract method is universal. (If an in-flight > this decision asked for — and is retained for exactly that role. **D-A3 — Add a temporal conformance matrix as the runtime regression backstop.** +*(Landed 2026-07-30, #4081 — `packages/spec/src/data/temporal-conformance.ts`, +consumed by five backends. Two axes and one measured limit are recorded in +D-A3.1 below.)* Cover `field-type {date, datetime} × operator {eq, gte/lte/gt/lt, in, dateRange} × relative-token {today, N_days_ago, N_months_ago, …} × driver {SQLite, Postgres at minimum}`, asserting correct **row results** — not just emitted SQL. Analytics has @@ -815,3 +818,65 @@ The write-side gap this closed was not cosmetic. A `check` policy of the shape **denied every write made after 00:00** — the write-side twin of the read-side loss #3777 fixed, on the one surface where the failure direction is a rejected write rather than a missing row. + +--- + +## Addendum (2026-07-30) — D-A3.1: the conformance matrix, and what it found + +> **Status:** landed (#4081). Closes D-A3, the last decision in this ADR that +> had never been actioned. + +### The shape: a shared table, not per-issue suites + +`packages/spec/src/data/temporal-conformance.ts` exports `TEMPORAL_ROWS` (a +fixture whose times of day make a midnight-anchored bound and a whole-day bound +distinguishable) and `TEMPORAL_CASES` (filter + expected row ids + a note naming +the issue each case defends). Five backends consume it and assert **row-id +sets** — the ADR's original requirement — through their own real entry points: +`driver-sql` (typed, and via the live-dialect CI job also real Postgres and +MySQL under a skewed process zone), `driver-memory`, `driver-mongodb` (real +MongoDB), the analytics preview evaluator, and `formula`'s write-side `check`. + +It is modelled on `filter-logic-conformance.ts`, which exists for the same +reason one layer down (#3774), and lives in `spec` for the D-D2 reason: every +backend already depends on it. + +Before this, each of the four fixes left a suite proving *its own* issue with +*its own* fixture. That is coverage without a standard: nothing could fail when +two backends drifted apart, which is precisely how #3650, #3773, #3777 and +#4047 each reached production. + +### D-A3.1 — What the matrix found on its first run + +Two things, which is the argument for having built it: + +1. **A real defect.** The preview evaluator had no `$between` case at all, so it + fell through to its permissive `default: return true` and matched **every** + row. A drafted dashboard carrying a range filter charted the whole dataset, + then changed its numbers at publish — the exact continuity the preview + exists to provide. Fixed in the same change, sharing the `$lte` bound helper + so the two cannot drift again. + +2. **A measured, irreducible limit.** `$gt` with a bare-day comparand on a + `datetime` column cannot agree across backends. A typed backend anchors the + bound to midnight and excludes a value stored at exactly 00:00; a type-blind + one compares raw strings, where `'2026-07-28T00:00:00.000Z'` sorts after + `'2026-07-28'`, and keeps it. The trick that makes the *upper* bound + type-blind — rewriting `<= day` as `< nextDay` — has no lower-bound + analogue: anchoring to `…T00:00:00.000Z` would fix `datetime` and break + `date`, since `'2026-07-28' >= '2026-07-28T00:00:00.000Z'` is false. + + So `$gt` is asserted in the shared matrix on the `date` column only, and the + `datetime` cell stays in the typed drivers' own suites. `$gte` **is** shared: + both readings include the midnight row — they agree because the boundary is + inclusive, which is luck rather than design, and is pinned here for exactly + that reason. + +### Consequences + +- 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 + (`{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. diff --git a/packages/formula/src/matches-filter-temporal-conformance.test.ts b/packages/formula/src/matches-filter-temporal-conformance.test.ts new file mode 100644 index 0000000000..a4d4e3fe8b --- /dev/null +++ b/packages/formula/src/matches-filter-temporal-conformance.test.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Temporal conformance for the RLS write-side `check` evaluator (ADR-0053 D-A3). + * + * The cases come from `@objectstack/spec/data` so this backend, the three + * drivers and the analytics preview are all held to one standard — see + * `temporal-conformance.ts` for the four divergences that standard exists to + * prevent. Adding a case there adds it here. + * + * This evaluator is type-blind: it sees a bare record with no schema, so both + * the `datetime` and `date` cases run against the raw string values. That both + * kinds pass unmodified is itself the assertion — it is what lets the rule be + * applied without a field-type lookup on this surface. + */ + +import { describe, it, expect } from 'vitest'; +import { TEMPORAL_CASES, TEMPORAL_ROWS } from '@objectstack/spec/data'; + +import { matchesFilterCondition } from './matches-filter'; + +describe('matchesFilterCondition — temporal conformance', () => { + for (const c of TEMPORAL_CASES) { + it(c.name, () => { + const got = TEMPORAL_ROWS.filter((r) => matchesFilterCondition(r, c.filter)).map((r) => r.id); + expect(got, c.note).toEqual(c.expected); + }); + } +}); diff --git a/packages/plugins/driver-memory/src/memory-temporal-conformance.test.ts b/packages/plugins/driver-memory/src/memory-temporal-conformance.test.ts new file mode 100644 index 0000000000..b2255b461a --- /dev/null +++ b/packages/plugins/driver-memory/src/memory-temporal-conformance.test.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Temporal conformance for the in-memory driver (ADR-0053 D-A3). + * + * The cases come from `@objectstack/spec/data` so this backend, `driver-sql`, + * `driver-mongodb`, the analytics preview and `formula`'s write-side `check` + * evaluator are all held to one standard — see `temporal-conformance.ts` for + * the four divergences that standard exists to prevent, one of which (#4047) + * was this driver's. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { TEMPORAL_CASES, TEMPORAL_ROWS } from '@objectstack/spec/data'; +import { InMemoryDriver } from './memory-driver.js'; + +describe('driver-memory — temporal conformance', () => { + let driver: InMemoryDriver; + + beforeAll(async () => { + driver = new InMemoryDriver({}); + await driver.connect(); + // Declaring the object is what teaches this driver the field types (#4047); + // without it the values would keep whatever form the writer produced. + 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_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()); + }); + } +}); diff --git a/packages/plugins/driver-mongodb/src/mongodb-temporal-conformance.test.ts b/packages/plugins/driver-mongodb/src/mongodb-temporal-conformance.test.ts new file mode 100644 index 0000000000..3b40b7eeb2 --- /dev/null +++ b/packages/plugins/driver-mongodb/src/mongodb-temporal-conformance.test.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Temporal conformance for the MongoDB driver (ADR-0053 D-A3), against a real + * MongoDB via `mongodb-memory-server`. + * + * The cases come from `@objectstack/spec/data` so this backend, `driver-sql`, + * `driver-memory`, the analytics preview and `formula`'s write-side `check` + * evaluator are all held to one standard — see `temporal-conformance.ts` for + * the four divergences that standard exists to prevent, one of which (#4047) + * was this driver's and was the worst of them: type-bracket comparison meant a + * string bound matched no BSON `Date` row at all, so the default dashboard + * window returned nothing. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { TEMPORAL_CASES, TEMPORAL_ROWS } from '@objectstack/spec/data'; +import { MongoDBDriver } from './mongodb-driver.js'; + +let sharedMongod: MongoMemoryServer | undefined; +try { + sharedMongod = await MongoMemoryServer.create({ instance: { launchTimeout: 60_000 } }); +} catch (err) { + console.warn( + '[driver-mongodb] Skipping temporal conformance — mongodb-memory-server could not start: ' + + `${(err as Error)?.message ?? String(err)}`, + ); +} + +describe.skipIf(!sharedMongod)('driver-mongodb — temporal conformance', () => { + const mongod = sharedMongod as MongoMemoryServer; + let driver: MongoDBDriver; + + beforeAll(async () => { + driver = new MongoDBDriver({ url: mongod.getUri(), database: 'temporal_conformance' }); + await driver.connect(); + // The declaration is what gives this driver its field types (#4047) — and + // therefore the BSON storage form its comparands must be coerced into. + 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 }); + } + }, 90_000); + + afterAll(async () => { + if (driver) await driver.disconnect(); + if (mongod) await mongod.stop(); + }); + + 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()); + }); + } +}); 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 new file mode 100644 index 0000000000..958275a8dd --- /dev/null +++ b/packages/plugins/driver-sql/src/sql-driver-temporal-conformance.test.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Temporal conformance for the SQL filter compiler (ADR-0053 D-A3). + * + * The cases come from `@objectstack/spec/data`, so this backend, the in-memory + * and document drivers, the analytics preview and `formula`'s write-side + * `check` evaluator are all held to one standard — see `temporal-conformance.ts` + * for the four divergences that standard exists to prevent. Adding a case there + * adds it here. + * + * This is the TYPED half of the matrix: the columns are declared through + * `initObjects`, so the driver knows `at` is a `Field.datetime` and `on` a + * `Field.date` and coerces comparands accordingly. That the same case yields the + * same row set here and in the type-blind backends is the whole assertion. + * + * Runs against a real SQLite, and — through the `Temporal Conformance + * (live PG + MySQL)` CI job, which runs this package's whole suite under + * `TZ=America/New_York` against servers on `Asia/Shanghai` — against real + * Postgres and MySQL too, with no workflow change needed. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { TEMPORAL_CASES, TEMPORAL_ROWS } from '@objectstack/spec/data'; +import { SqlDriver } from '../src/index.js'; + +describe('sql-driver — temporal conformance', () => { + let driver: SqlDriver; + + beforeAll(async () => { + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + // The declaration is what makes this the typed half — `at` is an instant, + // `on` a calendar day, and the driver's coercion follows from that. + 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_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()); + }); + } +}); 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 new file mode 100644 index 0000000000..5964fc22b5 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/preview-temporal-conformance.test.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Temporal conformance for the draft-preview evaluator (ADR-0053 D-A3). + * + * The cases come from `@objectstack/spec/data` so this backend, the three + * drivers and `formula`'s write-side `check` evaluator are all held to one + * standard — see `temporal-conformance.ts` for the four divergences that + * standard exists to prevent. + * + * This surface has its own reason to care: a Live Canvas dashboard charts REAL + * numbers from DRAFTED seed data, and publish materialises the same seed. If + * the preview and the driver disagree about a window, the numbers jump across + * the publish boundary — the continuity the preview exists to provide. + * + * Type-blind, like `formula`: it evaluates a bare row with no schema, so both + * the `datetime` and `date` cases run against the raw string values. + */ + +import { describe, it, expect } from 'vitest'; +import { TEMPORAL_CASES, TEMPORAL_ROWS } from '@objectstack/spec/data'; +import { matchesWhere } from '../preview-evaluator.js'; + +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); + }); + } +}); diff --git a/packages/services/service-analytics/src/preview-evaluator.ts b/packages/services/service-analytics/src/preview-evaluator.ts index e027c51159..0d9ff4f8fc 100644 --- a/packages/services/service-analytics/src/preview-evaluator.ts +++ b/packages/services/service-analytics/src/preview-evaluator.ts @@ -30,6 +30,21 @@ function compare(a: unknown, b: unknown): number { return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0; } +/** + * The inclusive-upper-bound comparison, with the calendar-day rule (#3777): a + * bare-day bound means "through that whole day", so it is evaluated half-open + * against the next day. String ordering makes `< nextDay` equivalent to + * `<= day` for plain date values, so this needs no field-type lookup — which + * matters here, because the preview sees drafted rows with no schema. + * + * Shared by `$lte` and the max of `$between` so the two cannot drift apart. + */ +function lteBound(value: unknown, bound: unknown): boolean { + const nextDay = nextUtcCalendarDay(bound); + if (nextDay != null) return compare(value, nextDay) < 0; + return compare(value, bound) <= 0; +} + function matchOp(value: unknown, op: string, expected: unknown): boolean { switch (op) { case '$eq': return value === expected || String(value) === String(expected); @@ -44,9 +59,17 @@ function matchOp(value: unknown, op: string, expected: unknown): boolean { // a drafted chart shows different numbers than the published one. String // ordering makes `< nextDay` equivalent to `<= day` for plain date // values, so no type lookup is needed here either. - const nextDay = nextUtcCalendarDay(expected); - if (nextDay != null) return compare(value, nextDay) < 0; - return compare(value, expected) <= 0; + return lteBound(value, expected); + } + case '$between': { + // Was absent, so it fell to the permissive `default` and matched EVERY + // row — a drafted chart with a range filter silently charted the whole + // dataset, then changed at publish (found by the ADR-0053 D-A3 matrix, + // #4081). The max takes the same whole-day rule as `$lte`. + if (value == null || !Array.isArray(expected) || expected.length !== 2) return false; + const [min, max] = expected; + if (min == null || max == null) return false; + return compare(value, min) >= 0 && lteBound(value, max); } case '$in': return Array.isArray(expected) && expected.some((e) => value === e || String(value) === String(e)); case '$nin': return Array.isArray(expected) && !expected.some((e) => value === e || String(value) === String(e)); diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 44976cf893..87b56e47d2 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -529,9 +529,14 @@ "StateMachineValidation (type)", "StateMachineValidationSchema (const)", "StringOperatorSchema (const)", + "TEMPORAL_CASES (const)", + "TEMPORAL_ROWS (const)", "TITLE_ELIGIBLE (const)", "TITLE_ELIGIBLE_TYPES (const)", "TITLE_INELIGIBLE_TYPES (const)", + "TemporalCase (interface)", + "TemporalFieldKind (type)", + "TemporalRow (interface)", "TenancyConfig (type)", "TenancyConfigSchema (const)", "TimeUpdateInterval (const)", diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index c3d69afeb8..e1c5389172 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -6,6 +6,7 @@ export * from './filter.zod'; // standard the four independent FilterCondition backends are each checked // against, so they cannot drift apart again (#3774). export * from './filter-logic-conformance'; +export * from './temporal-conformance'; export * from './date-macros.zod'; export * from './calendar-day'; // Session-scoped filter placeholders ({current_user_id} / {current_org_id}) — diff --git a/packages/spec/src/data/temporal-conformance.ts b/packages/spec/src/data/temporal-conformance.ts new file mode 100644 index 0000000000..d00c76670b --- /dev/null +++ b/packages/spec/src/data/temporal-conformance.ts @@ -0,0 +1,247 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Canonical conformance cases for **temporal filter semantics** — the single + * source of truth every filter backend is checked against (ADR-0053 D-A3). + * + * The twin of {@link FILTER_LOGIC_CASES}, built for the same reason and after + * the same kind of history. One temporal comparison is evaluated by six + * independent implementations: + * + * | Backend | Entry point | + * |---|---| + * | SQL compiler | `driver-sql` `applyFilterCondition` (+ `driver-sqlite-wasm`) | + * | In-memory store | `driver-memory` `convertToMongoQuery` → mingo | + * | Document store | `driver-mongodb` `translateFilter` | + * | Analytics raw SQL | `service-analytics` `NativeSQLStrategy` | + * | Draft preview | `service-analytics` `preview-evaluator` | + * | RLS write-side `check` | `formula` `matchesFilterCondition` | + * + * They disagreed four times, and every time a human found it by accident: + * #3650 (the window was dropped entirely), #3773 (buckets collapsed to NULL), + * #3777 (a bare-day upper bound dropped the final day — the default dashboard + * configuration), #4047 (cross-type comparison matched nothing at all on the + * non-SQL drivers). Each fix left a suite proving *its own* issue, with its own + * fixture; nothing held the backends to one standard, so the fifth divergence + * had nowhere to fail. + * + * Adding a case here adds it to every backend at once. That is the point. + * + * # What belongs here + * + * Only rules **every** backend must agree on: what a comparand *denotes*. + * Storage form is deliberately out of scope — it is per-dialect by design + * (ADR-0053 D-B/D-E: canonical UTC text on SQL, BSON `Date` on mongo), so it is + * asserted in each driver's own suite. What is shared is the *semantics* those + * storage forms have to preserve. + * + * # Why the values are strings + * + * Every row value is a string in the platform's wire form — canonical UTC ISO + * for `datetime`, `YYYY-MM-DD` for `date`. That is what lets one table serve + * both the typed backends (which coerce it into their storage form on write) + * and the type-blind ones (`formula`, the preview evaluator), which see a bare + * record with no schema to consult. A backend that needs richer input builds it + * from these strings; none needs less. + * + * # One cell is NOT shared: `$gt` with a bare day on a `datetime` column + * + * Measured, not assumed. A typed backend anchors the bound to midnight, so a + * value sitting exactly ON midnight is excluded: + * + * at > '2026-07-28' → at > '2026-07-28T00:00:00.000Z' → excludes 00:00 + * + * A type-blind one compares the raw strings, and `'2026-07-28T00:00:00.000Z'` + * sorts AFTER `'2026-07-28'` on the shared prefix, so it keeps that row. + * + * The gap is irreducible without field types. The trick that makes the upper + * bound type-blind — rewriting `<= day` as `< nextDay` — has no lower-bound + * analogue: anchoring the bound to `…T00:00:00.000Z` would fix `datetime` and + * break `date`, because `'2026-07-28' >= '2026-07-28T00:00:00.000Z'` is false. + * + * It bites only a value stored at exactly 00:00:00.000 under strict-greater, so + * `$gt` is asserted here on the `date` column (where every backend agrees) and + * the `datetime` cell is left to the typed drivers' own suites. `$gte` is + * shared: both readings include the midnight row, so they agree by luck of the + * boundary being inclusive — which is worth pinning precisely because it is + * luck. Tracked separately rather than papered over. + */ + +import type { FilterCondition } from './filter.zod'; + +/** + * A row in the temporal fixture. + * + * `at` is a `Field.datetime` (an instant) and `on` is a `Field.date` (a + * timezone-naive calendar day); the two carry the SAME calendar day per row, so + * a case can be run against either column and the difference in result is + * attributable to the field type rather than to the data. + */ +export interface TemporalRow { + id: string; + /** `Field.datetime` — canonical UTC ISO. */ + at: string; + /** `Field.date` — the calendar day of `at`, timezone-naive. */ + on: string; + /** Why the row is in the fixture — surfaced when a case fails. */ + why: string; +} + +/** + * The fixture. Every row exists to make one boundary decidable, and the times + * of day are chosen so a midnight-anchored bound and a whole-day bound cannot + * return the same set: `d_open` sits exactly ON midnight, `d_mid`/`d_late` + * strictly after it, and `d_next` on the next midnight — the exclusive edge. + */ +export const TEMPORAL_ROWS: readonly TemporalRow[] = [ + { id: 'a_old', at: '2026-04-19T10:00:00.000Z', on: '2026-04-19', why: 'well before any window here' }, + { id: 'b_prev', at: '2026-07-27T14:00:00.000Z', on: '2026-07-27', why: 'the day before the boundary day' }, + { id: 'c_open', at: '2026-07-28T00:00:00.000Z', on: '2026-07-28', why: 'boundary day at exactly 00:00 — the only instant a midnight-anchored bound keeps' }, + { id: 'd_mid', at: '2026-07-28T09:15:00.000Z', on: '2026-07-28', why: 'boundary day, morning — dropped by the #3777 bug' }, + { id: 'e_late', at: '2026-07-28T21:40:00.000Z', on: '2026-07-28', why: 'boundary day, evening — dropped by the #3777 bug' }, + { id: 'f_next', at: '2026-07-29T00:00:00.000Z', on: '2026-07-29', why: 'next midnight — the exclusive edge a half-open bound must NOT keep' }, + { id: 'g_eom', at: '2026-07-31T23:59:59.999Z', on: '2026-07-31', why: 'last representable instant of a month — month rollover' }, + { id: 'h_leap', at: '2024-02-29T12:00:00.000Z', on: '2024-02-29', why: 'leap day — February rollover' }, +] as const; + +/** Which declared field type a case filters on. */ +export type TemporalFieldKind = 'datetime' | 'date'; + +/** One conformance case: a filter on one column, and the ids it must match. */ +export interface TemporalCase { + /** Stable identifier, usable as a test name. */ + name: string; + /** + * Which fixture column — and therefore which declared field type — the + * filter is applied to. A typed backend declares the column accordingly; a + * type-blind one just reads the property. + */ + field: 'at' | 'on'; + kind: TemporalFieldKind; + filter: FilterCondition; + /** Ids of matching rows, ascending. */ + expected: string[]; + /** Why the case is here — surfaced in failure output. */ + note?: string; +} + +/** + * The cases, ordered from the rule outward: the whole-day upper bound that + * #3777 was about, then the operators that must NOT move, then the comparand + * shapes that must not be widened, then the rollovers. + */ +export const TEMPORAL_CASES: readonly TemporalCase[] = [ + // ── The rule: a bare-day UPPER bound denotes the whole day (D-D) ────────── + { + name: 'datetime: bare-day $lte keeps the whole final day', + field: 'at', + kind: 'datetime', + filter: { at: { $gte: '2026-04-29', $lte: '2026-07-28' } }, + expected: ['b_prev', 'c_open', 'd_mid', 'e_late'], + note: '#3777: the default dashboard window. Pre-fix returned only b_prev + c_open — everything after 00:00 on the final day vanished.', + }, + { + name: 'date: the same window is unchanged (already whole-day)', + field: 'on', + kind: 'date', + filter: { on: { $gte: '2026-04-29', $lte: '2026-07-28' } }, + expected: ['b_prev', 'c_open', 'd_mid', 'e_late'], + note: 'A `date` column compares as calendar-day text, so `<= day` was always right there. The two rows must agree — that is the invariant.', + }, + { + name: 'datetime: a bare-day $lte alone stops at the next midnight', + field: 'at', + kind: 'datetime', + filter: { at: { $lte: '2026-07-28' } }, + expected: ['a_old', 'b_prev', 'c_open', 'd_mid', 'e_late', 'h_leap'], + note: 'f_next sits exactly on the exclusive edge and must stay out — a half-open bound, never an inclusive 23:59:59.999.', + }, + { + name: 'datetime: $between inherits the rule on its max', + field: 'at', + kind: 'datetime', + filter: { at: { $between: ['2026-04-29', '2026-07-28'] } }, + expected: ['b_prev', 'c_open', 'd_mid', 'e_late'], + note: 'knex whereBetween is inclusive on both ends, so it had the same midnight-anchored upper bound $lte had.', + }, + { + name: 'datetime: $between still bounds its min', + field: 'at', + kind: 'datetime', + filter: { at: { $between: ['2026-07-28', '2026-07-28'] } }, + expected: ['c_open', 'd_mid', 'e_late'], + note: 'The "today" preset degenerates to a single day: the min stays midnight-anchored while the max spans the day.', + }, + + // ── Operators that must NOT move (the correct-as-written column) ────────── + { + name: 'datetime: bare-day $gte is that midnight', + field: 'at', + kind: 'datetime', + filter: { at: { $gte: '2026-07-28' } }, + expected: ['c_open', 'd_mid', 'e_late', 'f_next', 'g_eom'], + note: 'A LOWER bound anchors to 00:00 — c_open is included precisely because the bound is inclusive of that instant.', + }, + { + name: 'datetime: bare-day $lt is that midnight', + field: 'at', + kind: 'datetime', + filter: { at: { $lt: '2026-07-28' } }, + expected: ['a_old', 'b_prev', 'h_leap'], + note: 'Strict-less keeps its anchoring: c_open is AT midnight, so it is excluded.', + }, + { + // `$gt` is asserted on the `date` column only — see "One cell is not + // shared" in the module doc for why the `datetime` cell cannot be. + name: 'date: bare-day $gt excludes the bound day', + field: 'on', + kind: 'date', + filter: { on: { $gt: '2026-07-28' } }, + expected: ['f_next', 'g_eom'], + note: 'The boundary day itself is out; the mirror-image of widening a lower bound.', + }, + + // ── Comparand shapes that must not be widened ───────────────────────────── + { + name: 'datetime: a full-ISO $lte keeps exact-instant semantics', + field: 'at', + kind: 'datetime', + filter: { at: { $lte: '2026-07-28T12:00:00.000Z' } }, + expected: ['a_old', 'b_prev', 'c_open', 'd_mid', 'h_leap'], + note: 'Only the day-granular STRING carries whole-day intent. e_late (21:40) is after noon and must stay out.', + }, + { + name: 'datetime: a full-ISO $gte is exact too', + field: 'at', + kind: 'datetime', + filter: { at: { $gte: '2026-07-28T09:15:00.000Z' } }, + expected: ['d_mid', 'e_late', 'f_next', 'g_eom'], + note: 'd_mid is exactly the bound and inclusive.', + }, + + // ── Rollovers: the arithmetic has to be a calendar, not +86400000 ───────── + { + name: 'datetime: month-end $lte spans to the next month', + field: 'at', + kind: 'datetime', + filter: { at: { $gte: '2026-07-29', $lte: '2026-07-31' } }, + expected: ['f_next', 'g_eom'], + note: 'g_eom is 23:59:59.999 on the 31st — kept only if the bound rolled to 2026-08-01.', + }, + { + name: 'datetime: leap-day $lte includes Feb 29', + field: 'at', + kind: 'datetime', + filter: { at: { $gte: '2024-02-01', $lte: '2024-02-29' } }, + expected: ['h_leap'], + note: 'The next day is 2024-03-01; a naive month-increment would produce 2024-02-30.', + }, + { + name: 'datetime: the day BEFORE the leap day excludes it', + field: 'at', + kind: 'datetime', + filter: { at: { $gte: '2024-02-01', $lte: '2024-02-28' } }, + expected: [], + note: 'Guards the opposite error: rolling 02-28 to 03-01 (skipping the 29th) would wrongly include h_leap.', + }, +] as const; From d6e334cfa2e2aabced5d6aec883c797a9153c101 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 09:31:48 +0000 Subject: [PATCH 2/2] docs(service-analytics): list $between in the preview evaluator's declared scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module's scope comment enumerated the operators the preview supports and omitted $between — accurately, since it had no case and fell through to the permissive default. Now that it has one, the list has to say so. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkL3RGJrzjLEGURsLxfS2f --- packages/services/service-analytics/src/preview-evaluator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/services/service-analytics/src/preview-evaluator.ts b/packages/services/service-analytics/src/preview-evaluator.ts index 0d9ff4f8fc..89c8e95848 100644 --- a/packages/services/service-analytics/src/preview-evaluator.ts +++ b/packages/services/service-analytics/src/preview-evaluator.ts @@ -9,7 +9,7 @@ // // Scope (deliberately the dataset-query subset, not a general engine): // • Mongo-style `where` filters ($eq implicit, $ne/$gt/$gte/$lt/$lte/ -// $in/$nin/$contains, $and/$or/$not) +// $between/$in/$nin/$contains, $and/$or/$not) // • timeDimensions date-range filtering + granularity bucketing // (day/week/month/quarter/year) // • group-by dimensions; count / countDistinct / sum / avg / min / max