From 9d538ab32c11d8e9f5b8c25ef862428a68c6edd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 10:10:14 +0000 Subject: [PATCH 1/4] =?UTF-8?q?test(spec):=20temporal=20conformance=20matr?= =?UTF-8?q?ix=20=E2=80=94=20one=20standard=20for=20six=20date/datetime=20b?= =?UTF-8?q?ackends=20(ADR-0053=20D-A3)=20(#4081)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The temporal seam broke four times (#3650 window dropped entirely, #3773 epoch-as-Julian-day NULL buckets, #3777 bare-day upper bound losing the final day, #4047 type-bracket comparison returning nothing on memory/mongo), and each break was found by accident: every fix left a suite proving its own issue against its own fixture, with no shared standard to hold the six evaluation surfaces together. This adds the standard — the temporal twin of filter-logic-conformance (#3774), and the one ADR-0053 decision (D-A3) that had never been built: - `packages/spec/src/data/temporal-conformance.ts` exports `TEMPORAL_CONFORMANCE_ROWS` (one fixture spanning exact-midnight, intra-day, next-midnight, month-end last-millisecond, leap-day and pre-epoch instants, each tagged with a writer form for the D-E4 mixed-writer axis) and `TEMPORAL_CONFORMANCE_CASES` ({ name, fieldType, operator, filter, tokenFilter?, dateRange?, expected, note }) — field-type × operator × bound-semantics (D-D2) × relative-token cells, asserting row-id sets, never emitted SQL. Each note names the incident the case guards. - Six thin consumers: driver-sql (canonical + un-backfilled legacy storage; live PG/MySQL via the existing CI temporal job), driver-sqlite-wasm (inheritance guard), driver-memory and driver-mongodb (mixed-writer seeds), formula's matchesFilterCondition (RLS write-side check), and the analytics preview evaluator (where + timeDimensions.dateRange, the #3650 surface). - The token axis runs end-to-end: consumers that can reach @objectstack/core resolve each case's tokenFilter/dateRange against the pinned TEMPORAL_CONFORMANCE_NOW and must land on the same rows as the literal spelling, so resolver drift and evaluator drift are distinguishable at a glance. Deliberate scope, same discipline as the filter-logic table: a case belongs here only if every backend must agree on it. The schema-aware-only cells (bare-day $eq/$in on datetime, $gt at an exact stored midnight, Field.time) stay pinned in the per-driver suites and are documented as exclusions. Closes #4081. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TqqZmPS5a4gJGBoCTwipFr --- .changeset/temporal-conformance-matrix.md | 36 ++ ...atches-filter-temporal-conformance.test.ts | 44 +++ .../src/memory-temporal-conformance.test.ts | 70 ++++ .../src/mongodb-temporal-conformance.test.ts | 88 +++++ .../sql-driver-temporal-conformance.test.ts | 141 ++++++++ .../sqlite-wasm-temporal-conformance.test.ts | 74 ++++ ...iew-evaluator-temporal-conformance.test.ts | 96 ++++++ packages/spec/api-surface.json | 6 + packages/spec/src/data/index.ts | 4 + .../spec/src/data/temporal-conformance.ts | 317 ++++++++++++++++++ 10 files changed, 876 insertions(+) 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/plugins/driver-sqlite-wasm/src/sqlite-wasm-temporal-conformance.test.ts create mode 100644 packages/services/service-analytics/src/__tests__/preview-evaluator-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..9aba876a13 --- /dev/null +++ b/.changeset/temporal-conformance-matrix.md @@ -0,0 +1,36 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): temporal conformance matrix — the runtime regression backstop for date/datetime filter semantics (ADR-0053 D-A3, #4081) + +The temporal seam broke four times (#3650, #3773, #3777, #4047), and each break +was found by a user hitting it, not by a test: every fix left behind a suite +proving its own issue against its own fixture, so nothing held the six +evaluation surfaces to one standard and the fifth divergence would again be +invisible. + +`@objectstack/spec/data` now exports that standard — the temporal twin of +`FILTER_LOGIC_CASES` (#3774): + +- **`TEMPORAL_CONFORMANCE_ROWS`** — one fixture spanning the boundaries the + incidents turned on: an exact-midnight instant, intra-day times, the next + day's midnight, a month's last millisecond abutting the next month's first + instant, a leap day, a pre-epoch instant, and a `writerForm` tag (`wire` ISO + string vs `native` `Date`) so drivers seed genuinely mixed writer + populations (D-E4). +- **`TEMPORAL_CONFORMANCE_CASES`** — `{ name, fieldType, operator, filter, + tokenFilter?, dateRange?, expected, note }`: field-type × operator × + bound-semantics (point vs whole-day, D-D2) × relative-token cells, asserting + **row-id sets**, never emitted SQL. Each `note` names the incident the case + guards. +- **`TEMPORAL_CONFORMANCE_NOW`** — the pinned instant consumers hand to + `resolveFilterTokens`, so `{today}`/`{90_days_ago}`/period-token spellings + must produce the same rows as their literal twins. + +Six backends consume it through thin per-package tests: `driver-sql` (canonical ++ un-backfilled legacy storage; live PG/MySQL via CI's temporal job), +`driver-sqlite-wasm`, `driver-memory`, `driver-mongodb`, `formula`'s +`matchesFilterCondition`, and the analytics preview evaluator. A red cell now +names the backend that left the consensus and the issue it is about to +re-introduce — the signal all four incidents lacked. 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..ea3f4d0f2c --- /dev/null +++ b/packages/formula/src/matches-filter-temporal-conformance.test.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Temporal filter conformance for the record-at-a-time evaluator + * (ADR-0053 D-A3). + * + * The shared cases come from `@objectstack/spec/data` — see + * `temporal-conformance.ts` for the four incidents (#3650/#3773/#3777/#4047) + * the table exists to end. This evaluator's own stake is the write-side twin + * of #3777: a `check` policy of `{ signed_on: { $lte: '{today}' } }` against a + * `datetime` post-image denied every write made after 00:00 until it adopted + * the shared calendar-day rule (D-D2). + * + * The records are the fixture rows verbatim — canonical UTC ISO text for + * `datetime`, bare-day text for `date` — which is exactly what an RLS `check` + * sees in a post-image now that every driver stores one canonical form. + * + * Literal spellings only: this package deliberately depends on nothing but + * `spec` (the D-D2 dependency argument), and the `{token}` resolver lives in + * `@objectstack/core` — the token axis is swept by the five backends that can + * reach it. By the time a filter arrives here, tokens are already resolved. + */ + +import { describe, expect, it } from 'vitest'; +import { TEMPORAL_CONFORMANCE_CASES, TEMPORAL_CONFORMANCE_ROWS } from '@objectstack/spec/data'; + +import { matchesFilterCondition as m } from './matches-filter'; + +const RECORDS = TEMPORAL_CONFORMANCE_ROWS.map(({ id, happened_at, happened_on }) => ({ + id, + happened_at, + happened_on, +})); + +describe('matchesFilterCondition — temporal conformance', () => { + for (const c of TEMPORAL_CONFORMANCE_CASES) { + it(c.name, () => { + const got = RECORDS.filter((r) => m(r, c.filter)) + .map((r) => r.id) + .sort(); + 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..3746b2e1e8 --- /dev/null +++ b/packages/plugins/driver-memory/src/memory-temporal-conformance.test.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Temporal filter conformance for the in-memory driver (ADR-0053 D-A3). + * + * The shared cases come from `@objectstack/spec/data` — see + * `temporal-conformance.ts` for the four incidents (#3650/#3773/#3777/#4047) + * the table exists to end. This driver's own incident is #4047: mingo compares + * across JS types the way MongoDB compares across BSON types, so a mixed + * string/Date column answered a window with whichever half matched the + * comparand's type. The fixture's writer-form tags reproduce exactly that + * mixed-writer column through `create()`, and the conformance sweep proves the + * converged storage answers every shared case. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + TEMPORAL_CONFORMANCE_CASES, + TEMPORAL_CONFORMANCE_NOW, + TEMPORAL_CONFORMANCE_ROWS, +} from '@objectstack/spec/data'; +import { resolveFilterTokens } from '@objectstack/core'; +import { InMemoryDriver } from './memory-driver.js'; + +const ids = (rows: any[]) => rows.map((r: any) => String(r.id)).sort(); + +const resolveTokens = (filter: T): T => + resolveFilterTokens(filter, { now: new Date(TEMPORAL_CONFORMANCE_NOW) }); + +describe('InMemoryDriver temporal conformance', () => { + let driver: InMemoryDriver; + + beforeEach(async () => { + driver = new InMemoryDriver({}); + await driver.connect(); + // Declaring the object is what teaches the driver which fields are + // temporal (D-E2) — without it, no coercion happens at all. + await driver.syncSchema('task', { + name: 'task', + fields: { + title: { type: 'string' }, + happened_at: { type: 'datetime' }, + happened_on: { type: 'date' }, + }, + }); + for (const row of TEMPORAL_CONFORMANCE_ROWS) { + await driver.create('task', { + id: row.id, + title: row.id, + // The mixed-writer axis (D-E4): both shapes must converge on write. + happened_at: row.writerForm === 'native' ? new Date(row.happened_at) : row.happened_at, + happened_on: row.happened_on, + }); + } + }); + + for (const c of TEMPORAL_CONFORMANCE_CASES) { + it(c.name, async () => { + const found = await driver.find('task', { where: c.filter } as any); + expect(ids(found), c.note).toEqual(c.expected); + }); + + if (c.tokenFilter) { + it(`${c.name} — via relative tokens`, async () => { + const found = await driver.find('task', { where: resolveTokens(c.tokenFilter) } as any); + expect(ids(found), c.note).toEqual(c.expected); + }); + } + } +}); 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..a9eba5030b --- /dev/null +++ b/packages/plugins/driver-mongodb/src/mongodb-temporal-conformance.test.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Temporal filter conformance for the MongoDB driver, against a real server + * via mongodb-memory-server (ADR-0053 D-A3). + * + * The shared cases come from `@objectstack/spec/data` — see + * `temporal-conformance.ts` for the four incidents (#3650/#3773/#3777/#4047) + * the table exists to end. This driver's own incident is #4047, and it was the + * worst of the family: BSON type-bracket comparison meant a string comparand + * matched NO `Date` row for every operator, so the dashboard's default window + * returned nothing at all. The fixture's writer-form tags reproduce that mixed + * string/Date writer population through `create()`; the sweep proves the + * converged BSON-Date storage answers every shared case. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { + TEMPORAL_CONFORMANCE_CASES, + TEMPORAL_CONFORMANCE_NOW, + TEMPORAL_CONFORMANCE_ROWS, +} from '@objectstack/spec/data'; +import { resolveFilterTokens } from '@objectstack/core'; +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 suite — mongodb-memory-server could not start: ' + + `${(err as Error)?.message ?? String(err)}`, + ); +} + +const ids = (rows: any[]) => rows.map((r: any) => String(r.id)).sort(); + +const resolveTokens = (filter: T): T => + resolveFilterTokens(filter, { now: new Date(TEMPORAL_CONFORMANCE_NOW) }); + +describe.skipIf(!sharedMongod)('MongoDBDriver temporal conformance', () => { + const mongod = sharedMongod as MongoMemoryServer; + let driver: MongoDBDriver; + + beforeAll(async () => { + driver = new MongoDBDriver({ url: mongod.getUri(), database: 'temporal_conformance_db' }); + await driver.connect(); + // Declaring the object is what teaches the driver which fields are + // temporal (D-E2). Seed once — every case is a pure read. + await driver.syncSchema('task', { + name: 'task', + fields: { + title: { type: 'string' }, + happened_at: { type: 'datetime' }, + happened_on: { type: 'date' }, + }, + }); + for (const row of TEMPORAL_CONFORMANCE_ROWS) { + await driver.create('task', { + id: row.id, + title: row.id, + // The mixed-writer axis (D-E4): the exact population #4047 hit. + happened_at: row.writerForm === 'native' ? new Date(row.happened_at) : row.happened_at, + happened_on: row.happened_on, + }); + } + }, 90_000); + + afterAll(async () => { + if (driver) await driver.disconnect(); + if (mongod) await mongod.stop(); + }); + + for (const c of TEMPORAL_CONFORMANCE_CASES) { + it(c.name, async () => { + const found = await driver.find('task', { where: c.filter } as any); + expect(ids(found), c.note).toEqual(c.expected); + }); + + if (c.tokenFilter) { + it(`${c.name} — via relative tokens`, async () => { + const found = await driver.find('task', { where: resolveTokens(c.tokenFilter) } as any); + expect(ids(found), c.note).toEqual(c.expected); + }); + } + } +}); 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..65f9c6229c --- /dev/null +++ b/packages/plugins/driver-sql/src/sql-driver-temporal-conformance.test.ts @@ -0,0 +1,141 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Temporal filter conformance for the SQL compiler, on a real engine + * (in-memory better-sqlite3) — asserting ROW RESULTS, not emitted SQL + * (ADR-0053 D-A3). + * + * The shared cases come from `@objectstack/spec/data` so this backend, + * `driver-sqlite-wasm`, `driver-memory`, `driver-mongodb`, `formula`'s + * `matchesFilterCondition` and the analytics preview evaluator are all held to + * one standard — see `temporal-conformance.ts` for the four incidents + * (#3650/#3773/#3777/#4047) that standard exists to end. Adding a case there + * adds it to all six at once. + * + * Three sweeps here, because this driver owns two extra axes: + * 1. canonical storage — rows written through `create()`, which the D-B1 + * convention converges to canonical UTC text; + * 2. the same table again with each case's relative-token spelling resolved + * through `@objectstack/core`'s `resolveFilterTokens` (the D-A3 + * "token → row results" axis); + * 3. legacy storage — the same rows seeded RAW as pre-#3912 forms (INTEGER + * epoch ms / zone-naive TEXT) with the canonical marker cleared, so the + * read-repair path answers the same table. + * + * Under CI's `Temporal Conformance (live PG + MySQL)` job this whole file also + * runs against real non-UTC Postgres and MySQL servers — the server-timezone + * axis needs no extra code here. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + TEMPORAL_CONFORMANCE_CASES, + TEMPORAL_CONFORMANCE_NOW, + TEMPORAL_CONFORMANCE_ROWS, +} from '@objectstack/spec/data'; +import { resolveFilterTokens } from '@objectstack/core'; +import { SqlDriver } from './index.js'; +import { LegacyStorageDriver } from './legacy-datetime-storage.testkit.js'; + +const ids = (rows: any[]) => rows.map((r: any) => String(r.id)).sort(); + +const resolveTokens = (filter: T): T => + resolveFilterTokens(filter, { now: new Date(TEMPORAL_CONFORMANCE_NOW) }); + +const TASK = { + name: 'task', + fields: { + title: { type: 'string' }, + happened_at: { type: 'datetime' }, + happened_on: { type: 'date' }, + }, +}; + +describe('SqlDriver temporal conformance (SQLite, canonical storage)', () => { + let driver: SqlDriver; + + beforeEach(async () => { + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await driver.initObjects([TASK]); + for (const row of TEMPORAL_CONFORMANCE_ROWS) { + await driver.create( + 'task', + { + id: row.id, + title: row.id, + // The writer-form axis: `formatInput` must converge both shapes. + happened_at: row.writerForm === 'native' ? new Date(row.happened_at) : row.happened_at, + happened_on: row.happened_on, + }, + { bypassTenantAudit: true } as any, + ); + } + }); + + afterEach(async () => { + await driver.disconnect?.(); + }); + + for (const c of TEMPORAL_CONFORMANCE_CASES) { + it(c.name, async () => { + const found = await driver.find('task', { where: c.filter } as any); + expect(ids(found), c.note).toEqual(c.expected); + }); + + if (c.tokenFilter) { + it(`${c.name} — via relative tokens`, async () => { + const found = await driver.find('task', { where: resolveTokens(c.tokenFilter) } as any); + expect(ids(found), c.note).toEqual(c.expected); + }); + } + } +}); + +describe('SqlDriver temporal conformance (SQLite, un-backfilled legacy storage)', () => { + let driver: LegacyStorageDriver; + + beforeEach(async () => { + driver = new LegacyStorageDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await driver.initObjects([TASK]); + // The two pre-#3912 storage forms, split by the same writer-form tag: + // `native` writes were INTEGER epoch ms (a bound JS Date), `wire` writes + // were zone-naive TEXT (CURRENT_TIMESTAMP / REST payloads). One column, + // both forms — the read repair must answer the same table the canonical + // sweep does. (`happened_on` is unaffected: bare-day text has been the + // date canon since Phase 1.) + await driver.seedLegacyRows( + 'task', + 'happened_at', + TEMPORAL_CONFORMANCE_ROWS.map((row) => ({ + id: row.id, + title: row.id, + happened_at: + row.writerForm === 'native' + ? Date.parse(row.happened_at) + : row.happened_at.replace('T', ' ').replace('Z', ''), + happened_on: row.happened_on, + })), + ); + }); + + afterEach(async () => { + await driver.disconnect?.(); + }); + + // Literal spellings only: the token axis is orthogonal to storage form and + // already swept above — a divergence here is a repair-path bug by construction. + for (const c of TEMPORAL_CONFORMANCE_CASES) { + it(c.name, async () => { + const found = await driver.find('task', { where: c.filter } as any); + expect(ids(found), c.note).toEqual(c.expected); + }); + } +}); diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-temporal-conformance.test.ts b/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-temporal-conformance.test.ts new file mode 100644 index 0000000000..017fc94ae0 --- /dev/null +++ b/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-temporal-conformance.test.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Temporal filter conformance for the wasm driver (ADR-0053 D-A3). + * + * `SqliteWasmDriver extends SqlDriver`, so the whole temporal seam — canonical + * datetime storage (#3912), the calendar-day bound rewrites (#3777/#4042), the + * comparand coercion — is inherited, not re-implemented. This runs the shared + * `@objectstack/spec/data` conformance table against the wasm engine so the + * suite fails if this driver ever stops sharing that seam, the same guard the + * #3773/#3777 pin tests established one case at a time. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + TEMPORAL_CONFORMANCE_CASES, + TEMPORAL_CONFORMANCE_NOW, + TEMPORAL_CONFORMANCE_ROWS, +} from '@objectstack/spec/data'; +import { resolveFilterTokens } from '@objectstack/core'; +import { SqliteWasmDriver } from './index.js'; + +const ids = (rows: any[]) => rows.map((r: any) => String(r.id)).sort(); + +const resolveTokens = (filter: T): T => + resolveFilterTokens(filter, { now: new Date(TEMPORAL_CONFORMANCE_NOW) }); + +describe('SqliteWasmDriver temporal conformance', () => { + let driver: SqliteWasmDriver; + + beforeEach(async () => { + driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.initObjects([ + { + name: 'task', + fields: { + title: { type: 'string' }, + happened_at: { type: 'datetime' }, + happened_on: { type: 'date' }, + }, + }, + ]); + for (const row of TEMPORAL_CONFORMANCE_ROWS) { + await driver.create( + 'task', + { + id: row.id, + title: row.id, + happened_at: row.writerForm === 'native' ? new Date(row.happened_at) : row.happened_at, + happened_on: row.happened_on, + }, + { bypassTenantAudit: true } as any, + ); + } + }); + + afterEach(async () => { + await (driver as any).knex.destroy(); + }); + + for (const c of TEMPORAL_CONFORMANCE_CASES) { + it(c.name, async () => { + const found = await driver.find('task', { where: c.filter } as any); + expect(ids(found), c.note).toEqual(c.expected); + }); + + if (c.tokenFilter) { + it(`${c.name} — via relative tokens`, async () => { + const found = await driver.find('task', { where: resolveTokens(c.tokenFilter) } as any); + expect(ids(found), c.note).toEqual(c.expected); + }); + } + } +}); diff --git a/packages/services/service-analytics/src/__tests__/preview-evaluator-temporal-conformance.test.ts b/packages/services/service-analytics/src/__tests__/preview-evaluator-temporal-conformance.test.ts new file mode 100644 index 0000000000..b3c12c3787 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/preview-evaluator-temporal-conformance.test.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Temporal filter conformance for the dataset draft preview evaluator + * (ADR-0053 D-A3). + * + * The shared cases come from `@objectstack/spec/data` — see + * `temporal-conformance.ts` for the four incidents (#3650/#3773/#3777/#4047) + * the table exists to end. The preview's stake: a drafted chart must show the + * same numbers the published one computes through the engine strategies, so + * this evaluator has to agree with every driver about what a temporal filter + * matches — #3777's whole-day rule included. + * + * Two sweeps, because the preview has two temporal surfaces: + * 1. `matchesWhere` — the Mongo-style `where` subset, run over the fixture + * rows in their canonical read forms (literal and token spellings). Its + * DSL subset has no `$between`, and unknown operators are deliberately + * permissive there, so `operator: 'between'` cases are excluded rather + * than vacuously passed. + * 2. `timeDimensions.dateRange` via `evaluateAnalyticsQueryOverRows` — the + * dashboard window path, the surface #3650 broke (the range was dropped + * entirely and every row charted). + */ + +import { describe, expect, it } from 'vitest'; +import { + TEMPORAL_CONFORMANCE_CASES, + TEMPORAL_CONFORMANCE_NOW, + TEMPORAL_CONFORMANCE_ROWS, +} from '@objectstack/spec/data'; +import type { Cube } from '@objectstack/spec/data'; +import { resolveFilterTokens } from '@objectstack/core'; + +import { evaluateAnalyticsQueryOverRows, matchesWhere } from '../preview-evaluator.js'; + +const ROWS = TEMPORAL_CONFORMANCE_ROWS.map(({ id, happened_at, happened_on }) => ({ + id, + happened_at, + happened_on, +})); + +const resolveTokens = (filter: T): T => + resolveFilterTokens(filter, { now: new Date(TEMPORAL_CONFORMANCE_NOW) }); + +const matchIds = (where: Record) => + ROWS.filter((r) => matchesWhere(r, where)) + .map((r) => r.id) + .sort(); + +describe('preview-evaluator matchesWhere — temporal conformance', () => { + // `$between` is not part of the preview's where subset (unknown operators + // are permissive by design in a read-only preview), so those cases would + // pass vacuously — the spec table tags them so consumers like this one can + // exclude them visibly instead. + const CASES = TEMPORAL_CONFORMANCE_CASES.filter((c) => c.operator !== 'between'); + + for (const c of CASES) { + it(c.name, () => { + expect(matchIds(c.filter as Record), c.note).toEqual(c.expected); + }); + + if (c.tokenFilter) { + it(`${c.name} — via relative tokens`, () => { + expect(matchIds(resolveTokens(c.tokenFilter) as Record), c.note).toEqual(c.expected); + }); + } + } +}); + +describe('preview-evaluator timeDimensions.dateRange — temporal conformance', () => { + const CUBE = { + name: 'task_ds', + sql: 'task', + dimensions: { id: { name: 'id', type: 'string', sql: 'id' } }, + measures: { count: { name: 'count', type: 'count', sql: '*' } }, + } as unknown as Cube; + + const FIELD = { date: 'happened_on', datetime: 'happened_at' } as const; + + for (const c of TEMPORAL_CONFORMANCE_CASES) { + if (!c.dateRange) continue; + it(`${c.name} — via timeDimensions.dateRange`, () => { + const result = evaluateAnalyticsQueryOverRows( + { + measures: ['count'], + dimensions: ['id'], + timeDimensions: [{ dimension: FIELD[c.fieldType], dateRange: resolveTokens(c.dateRange) }], + }, + CUBE, + ROWS, + ); + const got = result.rows.map((r) => String(r.id)).sort(); + expect(got, c.note).toEqual(c.expected); + }); + } +}); diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index afec02227a..4ebafcd1da 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -529,9 +529,15 @@ "StateMachineValidation (type)", "StateMachineValidationSchema (const)", "StringOperatorSchema (const)", + "TEMPORAL_CONFORMANCE_CASES (const)", + "TEMPORAL_CONFORMANCE_NOW (const)", + "TEMPORAL_CONFORMANCE_ROWS (const)", "TITLE_ELIGIBLE (const)", "TITLE_ELIGIBLE_TYPES (const)", "TITLE_INELIGIBLE_TYPES (const)", + "TemporalConformanceCase (interface)", + "TemporalConformanceRow (interface)", + "TemporalWriterForm (type)", "TenancyConfig (type)", "TenancyConfigSchema (const)", "TimeUpdateInterval (const)", diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index c3d69afeb8..2cf66994ac 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -6,6 +6,10 @@ 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'; +// Canonical conformance cases for temporal filter semantics — the shared +// standard the six date/datetime evaluation surfaces are each checked against, +// so the seam that broke four times cannot silently drift again (ADR-0053 D-A3). +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..6b58fa0561 --- /dev/null +++ b/packages/spec/src/data/temporal-conformance.ts @@ -0,0 +1,317 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Canonical conformance cases for **temporal filter semantics** — the single + * source of truth every evaluation surface that compares a `date`/`datetime` + * comparand against stored rows is checked against (ADR-0053 D-A3). + * + * ## Why this exists + * + * The temporal seam broke four times, and each break was found by accident, + * not by a test: + * + * | Issue | Symptom | Fix | + * |---|---|---| + * | #3650 | analytics dropped a date window entirely → charted all history | PR #3766 | + * | #3773 | SQLite datetime bucketing read epoch ms as Julian days → NULL buckets | PR #3775 | + * | #3777 | bare-day upper bound anchored to midnight → lost the final day (default dashboard config) | PR #4041 / #4048 | + * | #4047 | memory/mongo compared string comparands against `Date` values by type → windows returned nothing | PR #4060 | + * + * Each fix left behind its own suite proving its own issue, with its own + * fixture. Nothing held the six evaluation surfaces to ONE standard, so the + * fifth divergence would again be invisible until a user hit it. This table is + * that standard — the temporal twin of {@link FILTER_LOGIC_CASES} (#3774), + * consumed the same way: each backend has a thin test that seeds + * {@link TEMPORAL_CONFORMANCE_ROWS} through its own write path and asserts the + * row-id sets in {@link TemporalConformanceCase.expected}. **Row results, not + * emitted SQL** — the ADR's hard requirement. + * + * | Backend | Where | + * |---|---| + * | SQL compiler (SQLite; live PG + MySQL in CI's temporal job) | `driver-sql` | + * | SQLite-wasm (inherits the SQL compiler) | `driver-sqlite-wasm` | + * | In-memory matcher | `driver-memory` | + * | MongoDB (real server via mongodb-memory-server) | `driver-mongodb` | + * | Record-at-a-time evaluator (RLS write-side `check`) | `formula` `matchesFilterCondition` | + * | Dataset draft preview | `service-analytics` `preview-evaluator` | + * + * ## The axes (D-A3, extended by D-D2 and D-E4) + * + * - **field-type**: {@link TemporalConformanceCase.fieldType} — `date` + * (tz-naive calendar day) vs `datetime` (UTC instant). + * - **operator**: eq, gte/gt/lt/lte windows, in, between, and the analytics + * `timeDimensions.dateRange` spelling ({@link TemporalConformanceCase.dateRange}). + * - **bound-semantics** (D-D2): point vs whole-day — a bare `YYYY-MM-DD` + * upper bound means the WHOLE day (compiled half-open, `< next-day`), while + * lower/strict bounds and full-ISO comparands anchor to the instant. + * - **relative-token**: {@link TemporalConformanceCase.tokenFilter} spells the + * same filter with `{today}` / `{90_days_ago}` / period tokens. Consumers + * resolve it via `@objectstack/core`'s `resolveFilterTokens` pinned to + * {@link TEMPORAL_CONFORMANCE_NOW} and must get the same ids — so a resolver + * drift and an evaluator drift are distinguishable at a glance. + * - **storage-form** (D-E4): {@link TemporalConformanceRow.writerForm} tags + * each row with a writer shape (`wire` ISO string vs `native` JS `Date`), so + * the drivers whose columns were mixed-form (#4047) seed a genuinely mixed + * table. `driver-sql` additionally re-runs the table over legacy epoch/naive + * storage via its `LegacyStorageDriver` testkit (#3912). + * - **driver**: every backend above. The SQL consumer also runs under CI's + * `Temporal Conformance (live PG + MySQL)` job (`ci.yml`), which supplies + * the non-UTC server-timezone axis for free. + * + * ## Deliberate scope — a case belongs here only if EVERY backend must agree + * + * Two consumers (`matchesFilterCondition`, the preview's `matchesWhere`) + * evaluate a bare record with **no schema**: they cannot canonicalise a + * comparand to a column's storage form, only compare canonical text + * lexicographically (which ISO-8601 makes chronological). Three cells are + * therefore *schema-aware-only* and deliberately absent: + * + * - `$eq` / `$in` with a bare day on a `datetime` column (means "the midnight + * instant" to a driver, plain string inequality to a schema-blind matcher); + * - `$gt` where a stored instant sits exactly AT the bound's midnight (the + * driver excludes it; lexicographic text compare includes it); + * - `Field.time` (D-C) — its own convention, its own suites. + * + * Those cells stay pinned where they always were — the per-driver suites + * (`sql-driver-calendar-day-upper-bound.test.ts` and kin). Every case below + * either avoids the ambiguous shape (no fixture row at a `$gt` bound's + * midnight) or targets a `date` column, where text equality IS the contract. + */ + +import type { FilterCondition } from './filter.zod'; + +/** + * The pinned reference instant for resolving {@link TemporalConformanceCase.tokenFilter} + * and {@link TemporalConformanceCase.dateRange}: consumers pass + * `{ now: new Date(TEMPORAL_CONFORMANCE_NOW) }` (UTC, no timezone) to + * `resolveFilterTokens`. Mid-day, so no token resolution sits on a day + * boundary; `{today}` = `2026-07-28`, matching the #3777/#4047 incident + * fixtures. + */ +export const TEMPORAL_CONFORMANCE_NOW = '2026-07-28T12:00:00.000Z'; + +/** + * Which write-path shape a consumer should seed the row through, where the + * distinction exists (D-E4 `mixed-writer-form`): `wire` = the ISO-8601 string + * a REST/JSON write delivers; `native` = a JS `Date`, what SDK callers and the + * drivers' own timestamp defaults produce. Both must converge to one stored + * form — the columns that held both at once are how #4047 happened. + */ +export type TemporalWriterForm = 'wire' | 'native'; + +/** A row in the conformance fixture. */ +export interface TemporalConformanceRow { + id: string; + /** Canonical UTC instant, `YYYY-MM-DDTHH:MM:SS.sssZ` (D-B1). */ + happened_at: string; + /** The instant's UTC calendar day — the row's `Field.date` value. */ + happened_on: string; + /** Writer shape for the mixed-writer-form axis — see {@link TemporalWriterForm}. */ + writerForm: TemporalWriterForm; +} + +/** + * The fixture, chronological. Every boundary the four incidents turned on + * appears as a row: a pre-epoch instant (negative epoch ms), a leap day, the + * last millisecond of a month abutting the next month's first instant, a + * window edge, and a full day of instants (its exact midnight, two intra-day + * times, and the NEXT day's midnight — the row a whole-day upper bound must + * exclude). Writer forms alternate so both shapes appear inside and outside + * every window. + */ +export const TEMPORAL_CONFORMANCE_ROWS: readonly TemporalConformanceRow[] = [ + { id: 'pre_epoch', happened_at: '1969-12-31T23:00:00.000Z', happened_on: '1969-12-31', writerForm: 'wire' }, + { id: 'leap', happened_at: '2024-02-29T12:00:00.000Z', happened_on: '2024-02-29', writerForm: 'native' }, + { id: 'window_out', happened_at: '2026-04-19T10:00:00.000Z', happened_on: '2026-04-19', writerForm: 'wire' }, + { id: 'month_end', happened_at: '2026-06-30T23:59:59.999Z', happened_on: '2026-06-30', writerForm: 'wire' }, + { id: 'month_start', happened_at: '2026-07-01T00:00:00.000Z', happened_on: '2026-07-01', writerForm: 'native' }, + { id: 'week_edge', happened_at: '2026-07-21T08:30:00.000Z', happened_on: '2026-07-21', writerForm: 'wire' }, + { id: 'yesterday', happened_at: '2026-07-27T14:00:00.000Z', happened_on: '2026-07-27', writerForm: 'native' }, + { id: 'midnight', happened_at: '2026-07-28T00:00:00.000Z', happened_on: '2026-07-28', writerForm: 'native' }, + { id: 'morning', happened_at: '2026-07-28T09:15:00.000Z', happened_on: '2026-07-28', writerForm: 'wire' }, + { id: 'evening', happened_at: '2026-07-28T21:40:00.000Z', happened_on: '2026-07-28', writerForm: 'wire' }, + { id: 'next_midnight', happened_at: '2026-07-29T00:00:00.000Z', happened_on: '2026-07-29', writerForm: 'native' }, +] as const; + +/** One conformance case: a temporal filter and the row ids it must match. */ +export interface TemporalConformanceCase { + /** Stable identifier, usable as a test name. */ + name: string; + /** Which fixture column the filter targets: `happened_at` or `happened_on`. */ + fieldType: 'date' | 'datetime'; + /** + * Operator-axis family. `between` exists so the one consumer whose DSL + * subset has no `$between` (the analytics preview `where`) can skip those + * cases visibly instead of evaluating them permissively. + */ + operator: 'eq' | 'in' | 'range' | 'between'; + /** The filter with literal comparands — ground truth for all six backends. */ + filter: FilterCondition; + /** + * The same filter spelled with relative-date tokens. Consumers that can + * reach `@objectstack/core` resolve it against {@link TEMPORAL_CONFORMANCE_NOW} + * and assert the same {@link expected} — end-to-end "token → row results". + */ + tokenFilter?: FilterCondition; + /** + * The analytics `timeDimensions.dateRange` spelling of the same window + * (tokens allowed), for the preview/dataset path — the surface #3650 broke. + */ + dateRange?: [string, string]; + /** Ids of matching rows, ascending (plain ASCII sort). */ + expected: string[]; + /** Why the case is here — surfaced in failure output. */ + note?: string; +} + +/** + * The cases. Ordered: whole-day upper bounds (the #3777 family), then + * calendar-boundary rollovers, then point-semantics anchors, then the `date` + * column half of the contract. + */ +export const TEMPORAL_CONFORMANCE_CASES: readonly TemporalConformanceCase[] = [ + // ── Whole-day upper bounds (bound-semantics: whole-day) ─────────────────── + { + name: 'a 90-day dashboard window keeps the whole final day', + fieldType: 'datetime', + operator: 'range', + filter: { happened_at: { $gte: '2026-04-29', $lte: '2026-07-28' } }, + tokenFilter: { happened_at: { $gte: '{90_days_ago}', $lte: '{today}' } }, + dateRange: ['{90_days_ago}', '{today}'], + expected: ['evening', 'midnight', 'month_end', 'month_start', 'morning', 'week_edge', 'yesterday'], + note: '#3777: the midnight-anchored $lte dropped morning+evening; #4047: on mongo the whole window returned []. The dashboard default config (created_at × last_90_days) compiles exactly this.', + }, + { + name: 'the today preset spans the whole current day', + fieldType: 'datetime', + operator: 'range', + filter: { happened_at: { $gte: '2026-07-28', $lte: '2026-07-28' } }, + tokenFilter: { happened_at: { $gte: '{today}', $lte: '{today}' } }, + dateRange: ['{today}', '{today}'], + expected: ['evening', 'midnight', 'morning'], + note: '#3777: 7 of the 13 dashboard presets end "today"; pre-fix a same-day window matched only the exact-midnight row. next_midnight must stay out — half-open, not next-day-inclusive.', + }, + { + name: '$between with a bare-day max covers the whole final day', + fieldType: 'datetime', + operator: 'between', + filter: { happened_at: { $between: ['2026-04-29', '2026-07-28'] } }, + tokenFilter: { happened_at: { $between: ['{90_days_ago}', '{today}'] } }, + expected: ['evening', 'midnight', 'month_end', 'month_start', 'morning', 'week_edge', 'yesterday'], + note: '#4042: $between decomposes to `>= min AND < next-day(max)`; must answer identically to the $gte/$lte spelling.', + }, + { + name: 'a full-ISO upper bound keeps instant semantics', + fieldType: 'datetime', + operator: 'range', + filter: { happened_at: { $lte: '2026-07-28T12:00:00.000Z' } }, + expected: ['leap', 'midnight', 'month_end', 'month_start', 'morning', 'pre_epoch', 'week_edge', 'window_out', 'yesterday'], + note: 'D-D2 bound-semantics: only a BARE day widens to the whole day. An instant comparand is a point — evening (21:40) must stay out.', + }, + + // ── Calendar-boundary rollovers ─────────────────────────────────────────── + { + name: 'the last millisecond of a month survives its whole-day upper bound', + fieldType: 'datetime', + operator: 'range', + filter: { happened_at: { $gte: '2026-06-01', $lte: '2026-06-30' } }, + tokenFilter: { happened_at: { $gte: '{last_month_start}', $lte: '{last_month_end}' } }, + dateRange: ['{last_month_start}', '{last_month_end}'], + expected: ['month_end'], + note: 'month_end sits at 23:59:59.999 and month_start at the NEXT instant: the bound must roll to `< 2026-07-01`, keeping the one and excluding the other. An inclusive 23:59:59.999 rewrite passes on ms-precision stores but re-opens the gap wherever sub-ms precision exists (Postgres keeps µs) — half-open is the pinned shape.', + }, + { + name: 'a leap-day window rolls to March 1', + fieldType: 'datetime', + operator: 'range', + filter: { happened_at: { $gte: '2024-02-29', $lte: '2024-02-29' } }, + expected: ['leap'], + note: 'nextUtcCalendarDay(2024-02-29) is 2024-03-01; day-string arithmetic that invents 2024-02-30 matches nothing.', + }, + { + name: 'a pre-epoch day is an ordinary calendar day', + fieldType: 'datetime', + operator: 'range', + filter: { happened_at: { $gte: '1969-12-31', $lte: '1969-12-31' } }, + expected: ['pre_epoch'], + note: 'Negative epoch ms. The #3773 family: any surface that assumes a datetime is a non-negative epoch (or a Julian day) breaks here first.', + }, + + // ── Point-semantics anchors (bound-semantics: point) ────────────────────── + { + name: '$lt of a bare day stops at that midnight', + fieldType: 'datetime', + operator: 'range', + filter: { happened_at: { $lt: '2026-07-28' } }, + tokenFilter: { happened_at: { $lt: '{today}' } }, + expected: ['leap', 'month_end', 'month_start', 'pre_epoch', 'week_edge', 'window_out', 'yesterday'], + note: 'D-D1: lower/strict bounds anchor to 00:00. The exact-midnight row is NOT before its own day.', + }, + { + name: '$gte of a bare day starts at that midnight, inclusive', + fieldType: 'datetime', + operator: 'range', + filter: { happened_at: { $gte: '2026-07-28' } }, + tokenFilter: { happened_at: { $gte: '{today}' } }, + expected: ['evening', 'midnight', 'morning', 'next_midnight'], + note: '#4047: on mongo a string bound matched no Date row at all, for every operator — $gte included.', + }, + { + name: '$gt of a bare day is midnight-anchored', + fieldType: 'datetime', + operator: 'range', + filter: { happened_at: { $gt: '2026-07-27' } }, + tokenFilter: { happened_at: { $gt: '{yesterday}' } }, + expected: ['evening', 'midnight', 'morning', 'next_midnight', 'yesterday'], + note: 'Deliberate scope: no fixture row sits exactly AT this bound\'s midnight — whether $gt excludes that instant is a schema-aware cell, pinned in sql-driver-calendar-day-upper-bound.test.ts.', + }, + + // ── The `date` column half of the contract ──────────────────────────────── + { + name: 'date equality against a resolved {today}', + fieldType: 'date', + operator: 'eq', + filter: { happened_on: '2026-07-28' }, + tokenFilter: { happened_on: '{today}' }, + expected: ['evening', 'midnight', 'morning'], + note: "The ADR's original defect (#1874): `date == today` silently matched nothing while dates were stored as instants. Equality on a date column is plain text equality — Phase 1's whole point.", + }, + { + name: 'date $in of two resolved days', + fieldType: 'date', + operator: 'in', + filter: { happened_on: { $in: ['2026-07-28', '2026-07-27'] } }, + tokenFilter: { happened_on: { $in: ['{today}', '{yesterday}'] } }, + expected: ['evening', 'midnight', 'morning', 'yesterday'], + note: 'The `expires_on: { $in: [daysFromNow(30)] }` template shape from the #1874 family.', + }, + { + name: 'the 90-day window answers identically on date and datetime', + fieldType: 'date', + operator: 'range', + filter: { happened_on: { $gte: '2026-04-29', $lte: '2026-07-28' } }, + tokenFilter: { happened_on: { $gte: '{90_days_ago}', $lte: '{today}' } }, + dateRange: ['{90_days_ago}', '{today}'], + expected: ['evening', 'midnight', 'month_end', 'month_start', 'morning', 'week_edge', 'yesterday'], + note: 'Same ids as the datetime case by construction: whole-day window semantics are field-type-independent. A backend that widens or narrows only one of the two field types fails exactly one of the pair.', + }, + { + name: 'a date column $lte the last day of a month keeps that day', + fieldType: 'date', + operator: 'range', + filter: { happened_on: { $lte: '2026-06-30' } }, + tokenFilter: { happened_on: { $lte: '{last_month_end}' } }, + expected: ['leap', 'month_end', 'pre_epoch', 'window_out'], + note: 'On date text `< next-day` is order-equivalent to `<= day` (D-D1) — what lets type-blind emitters rewrite unconditionally. Also the write-side twin: a `check` policy `{ $lte: \'{today}\' }` must not deny same-day rows.', + }, + { + name: 'a month window via period tokens', + fieldType: 'datetime', + operator: 'range', + filter: { happened_at: { $gte: '2026-07-01', $lte: '2026-07-31' } }, + tokenFilter: { happened_at: { $gte: '{current_month_start}', $lte: '{current_month_end}' } }, + dateRange: ['{current_month_start}', '{current_month_end}'], + expected: ['evening', 'midnight', 'month_start', 'morning', 'next_midnight', 'week_edge', 'yesterday'], + note: "{current_month_end} names the last calendar DAY (2026-07-31), per the vocabulary's documented refusal to widen; the whole-day bound rule is what makes its $lte include the 31st's instants. The two halves of the contract composing (filter-tokens.ts module doc × D-D1).", + }, +] as const; From 944bf7a287d92390f54285c73d6d43289f3fa2e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 10:42:28 +0000 Subject: [PATCH 2/4] fix(service-analytics): the matrix's sixth consumer, and the dropped $between it found (ADR-0053 D-A3.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NativeSQLStrategy is the surface #3650 was actually about, and it was listed in the conformance matrix's own backend table with no consumer — because every existing suite for it asserts the emitted SQL string, and a DROPPED predicate is invisible to a string assertion: the SQL stays valid, just wider. Executing the shared cases against a real engine (sql.js, the same pure-WASM engine driver-sql falls back to) and asserting row ids is what D-A3 asked for, and it immediately showed `$between` returning the entire table. The cause sat one layer below the strategy, in the shared filter-normalizer: `$between` was absent from MONGO_TO_CUBE_OP, and an unmapped operator is `continue`d, so the predicate vanished from the compiled WHERE clause. Both strategies read that normalizer, so the ObjectQL aggregate path was affected too. User-visible symptom: a widget with a range filter charts the whole dataset, with nothing in the SQL to suggest a filter was ever requested. `$between [min, max]` now LOWERS to its two bounds (gte + lte) rather than gaining an operator of its own, so a range's max inherits the calendar-day whole-day rule (#3777) from each strategy's existing upper-bound handling — NativeSQLStrategy compiles a bare-day upper bound half-open itself, the ObjectQL path gets the same rule from the driver — instead of needing a second implementation to keep in step. That is how #4098 closed the same defect on the preview evaluator. A malformed `$between` throws rather than being dropped, the stance driver-memory took for the same shape in #3948. The consumer runs the literal, relative-token and timeDimensions.dateRange spellings of every shared case. Rows are seeded canonical, where the driver's temporal hooks are identities, so the context omits them and exercises the same "absent = identity" path Postgres takes (D-A2); the un-backfilled mixed column stays covered where the driver truth lives. The cause itself is NOT fully closed: `$startsWith`, `$endsWith`, `$null` and `$regex` are still silently dropped by the same `continue`. Filed as #4128 rather than expanded into this change, with the #3948 precedent for turning the fallback into a throw. service-analytics 391 green (was 360; +31 from the new consumer). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TqqZmPS5a4gJGBoCTwipFr --- .../analytics-between-predicate-dropped.md | 29 +++ docs/adr/0053-date-and-datetime-semantics.md | 19 ++ .../native-sql-temporal-conformance.test.ts | 174 ++++++++++++++++++ .../src/strategies/filter-normalizer.ts | 31 ++++ 4 files changed, 253 insertions(+) create mode 100644 .changeset/analytics-between-predicate-dropped.md create mode 100644 packages/services/service-analytics/src/__tests__/native-sql-temporal-conformance.test.ts diff --git a/.changeset/analytics-between-predicate-dropped.md b/.changeset/analytics-between-predicate-dropped.md new file mode 100644 index 0000000000..ac54a581ef --- /dev/null +++ b/.changeset/analytics-between-predicate-dropped.md @@ -0,0 +1,29 @@ +--- +"@objectstack/service-analytics": patch +--- + +fix(service-analytics): a `$between` analytics filter no longer vanishes from the query (ADR-0053 D-A3.1) + +A dashboard widget or dataset whose filter used `$between` was querying **every +row**. `normalizeAnalyticsFilters` maps Mongo-style operators onto the internal +pipeline form, `$between` was missing from that map, and an unmapped operator is +skipped — so the predicate was silently dropped from the compiled WHERE clause. +Both strategies read that normalizer, so both the raw-SQL and the ObjectQL +aggregate paths were affected. The symptom is #3650's: a chart that draws the +whole dataset instead of the requested window, with nothing in the SQL to +suggest a filter was ever asked for. + +`$between [min, max]` now lowers to its two bounds (`gte` + `lte`) instead of +gaining an operator of its own, so a range's max inherits the calendar-day +whole-day rule (#3777) from each strategy's existing upper-bound handling — +`NativeSQLStrategy` compiles a bare-day upper bound half-open itself, and the +ObjectQL path gets the same rule from the driver — rather than needing a second +implementation to keep in step. A malformed `$between` (not a two-element +array) now throws instead of being dropped, matching the stance driver-memory +took for the same shape in #3948: an unbounded read is exactly the failure this +prevents, and it is indistinguishable from a legitimately wide query. + +Found by giving the temporal conformance matrix its missing sixth consumer +(`native-sql-temporal-conformance.test.ts`), which executes the shared cases +against a real SQLite engine and asserts row ids — a dropped predicate is +invisible to the SQL-string assertions the strategy's other suites use. diff --git a/docs/adr/0053-date-and-datetime-semantics.md b/docs/adr/0053-date-and-datetime-semantics.md index 48baf72221..3873a6dbb9 100644 --- a/docs/adr/0053-date-and-datetime-semantics.md +++ b/docs/adr/0053-date-and-datetime-semantics.md @@ -857,6 +857,25 @@ Two things, which is the argument for having built it: exists to provide. Fixed in the same change, sharing the `$lte` bound helper so the two cannot drift again. + **The same defect had a twin one layer over, found the same way when the + sixth consumer was added (#4081 follow-up).** `NativeSQLStrategy` — the + surface #3650 was actually about — was listed in the matrix's own backend + table but had no consumer, because every existing suite for it asserts the + emitted SQL string, and a *dropped* predicate is invisible to a string + assertion: the SQL stays valid, just wider. Executing the matrix against a + real engine showed `$between` returning the **entire table**. The cause was + one layer below the strategy, in the shared `filter-normalizer`: `$between` + was absent from `MONGO_TO_CUBE_OP`, and an unmapped operator is `continue`d, + so the predicate vanished from the WHERE clause. Both raw-SQL and ObjectQL + strategies read that normalizer, so both were affected. `$between` now + **lowers to its two bounds** (`gte` + `lte`) rather than gaining an operator + of its own: each strategy's upper-bound arm already carries the whole-day + calendar rule (`NativeSQLStrategy` compiles half-open directly, the ObjectQL + path inherits it from the driver), so a range's max gets that rule by + construction instead of via a second implementation. A malformed `$between` + now throws, the anti-silent-widening stance driver-memory took for the same + shape in #3948. + 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 diff --git a/packages/services/service-analytics/src/__tests__/native-sql-temporal-conformance.test.ts b/packages/services/service-analytics/src/__tests__/native-sql-temporal-conformance.test.ts new file mode 100644 index 0000000000..e741855c51 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/native-sql-temporal-conformance.test.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Temporal conformance for the analytics raw-SQL strategy (ADR-0053 D-A3), + * executed against a real SQLite engine (`sql.js`, pure WASM). + * + * The cases come from `@objectstack/spec/data` so this backend, the three + * drivers, the draft 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. + * + * ## Why this surface needed its own consumer + * + * `NativeSQLStrategy` is the surface #3650 broke — the dashboard window was + * dropped and the chart drew all history — and it is the one listed in the + * matrix's own backend table that had no consumer. It is also the only backend + * that hand-compiles SQL text rather than delegating to a driver's compiler, so + * "the other backends are green" says nothing about it. + * + * Every other suite for this strategy (`native-sql-datetime-filter.test.ts`, + * `native-sql-datetime-filter-column.test.ts`) asserts the emitted SQL string. + * That is a different question, with a lower ceiling: a string assertion checks + * the compiler emits what its author wrote down, and a dropped predicate is + * invisible to it — the SQL is still valid, just wider. This file asserts ROW + * IDS, which is what D-A3 demanded and what catches a predicate that silently + * went missing. (It found one: see the `$between` note in + * `filter-normalizer.ts`.) + * + * ## Storage form + * + * Rows are seeded in the canonical post-#3912 form — UTC ISO text for the + * `datetime` column, bare `YYYY-MM-DD` for the `date` one — which is the state + * a backfilled deployment is in, and the state in which the driver's + * `temporalFilterValue` / `temporalFilterColumnSql` hooks are identities. The + * context therefore omits them, exercising the same "absent = identity" path a + * Postgres deployment takes (ADR-0053 D-A2). The un-backfilled mixed column is + * a different axis, covered where the driver truth lives: + * `native-sql-datetime-filter-column.test.ts` for the emitted normalisation and + * `driver-sql`'s own legacy sweep for row results. + * + * ## Why `sql.js` and not `better-sqlite3` + * + * Same reason as `read-scope-sql-conformance.test.ts`: the native binding is + * loadable only by the exact Node ABI it was built for and aborts the vitest + * worker on CI's Node, taking the whole file's cases silently with it. `sql.js` + * is the pure-WASM engine `driver-sql` itself falls back to. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { TEMPORAL_CASES, TEMPORAL_NOW, TEMPORAL_ROWS } from '@objectstack/spec/data'; +import type { Cube } from '@objectstack/spec/data'; +import type { AnalyticsQuery, StrategyContext } from '@objectstack/spec/contracts'; +import { resolveFilterTokens } from '@objectstack/core'; + +import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js'; + +/** + * Dimension ids match the fixture's property names (`at` / `on`) so the shared + * cases apply unchanged, while the COLUMNS are deliberately different + * (`happened_at` / `happened_on`) — that is what proves the strategy resolved + * the real column rather than echoing the member name. + */ +const CUBE: Cube = { + name: 'conformance', + title: 'Conformance', + sql: 'conformance', + measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } }, + dimensions: { + id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, + at: { name: 'at', label: 'At', type: 'time', sql: 'happened_at' }, + on: { name: 'on', label: 'On', type: 'time', sql: 'happened_on' }, + }, + public: false, +} as unknown as Cube; + +const resolveTokens = (filter: T): T => + resolveFilterTokens(filter, { now: new Date(TEMPORAL_NOW) }); + +/** Point sql.js at the `.wasm` shipped inside its own package (Node-safe). */ +async function locateWasm(): Promise<((file: string) => string) | undefined> { + try { + const { createRequire } = await import('node:module'); + const require = createRequire(import.meta.url); + const pkgJsonPath = require.resolve('sql.js/package.json'); + const { dirname, join } = await import('node:path'); + const dir = dirname(pkgJsonPath); + return (file: string) => join(dir, 'dist', file); + } catch { + return undefined; + } +} + +describe('NativeSQLStrategy — temporal conformance', () => { + let db: any; + let ctx: StrategyContext; + + beforeAll(async () => { + const mod: any = await import('sql.js'); + const initSqlJs = mod.default ?? mod; + const locateFile = await locateWasm(); + const SQL = await initSqlJs(locateFile ? { locateFile } : undefined); + + db = new SQL.Database(); + db.run(` + CREATE TABLE "conformance" ( + "id" TEXT PRIMARY KEY, + "happened_at" TEXT, + "happened_on" TEXT, + "why" TEXT + ); + `); + const insert = db.prepare( + `INSERT INTO "conformance" ("id","happened_at","happened_on","why") VALUES (?,?,?,?)`, + ); + for (const r of TEMPORAL_ROWS) insert.run([r.id, r.at, r.on, r.why]); + insert.free(); + + ctx = { + getCube: (name: string) => (name === 'conformance' ? CUBE : undefined), + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + // The strategy binds `$1`-style placeholders in ascending order, each + // pushed immediately before it is referenced, so a positional rewrite to + // SQLite's `?` preserves the pairing. + executeRawSql: async (_object: string, sql: string, params: unknown[]) => { + const stmt = db.prepare(sql.replace(/\$\d+/g, '?')); + stmt.bind(params as any[]); + const out: Record[] = []; + while (stmt.step()) out.push(stmt.getAsObject()); + stmt.free(); + return out; + }, + } as StrategyContext; + }); + + afterAll(() => { + db?.close(); + }); + + /** Group by `id` so the result rows ARE the matched row ids. */ + const idsFor = async (query: Omit) => { + const result = await new NativeSQLStrategy().execute( + { cube: 'conformance', measures: ['total'], dimensions: ['id'], ...query } as AnalyticsQuery, + ctx, + ); + return result.rows.map((r) => String(r.id)).sort(); + }; + + for (const c of TEMPORAL_CASES) { + it(c.name, async () => { + expect(await idsFor({ where: c.filter }), c.note).toEqual([...c.expected].sort()); + }); + + // 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) { + it(`${c.name} — via relative tokens`, async () => { + expect(await idsFor({ where: resolveTokens(c.tokenFilter) }), c.note).toEqual( + [...c.expected].sort(), + ); + }); + } + + // The dashboard-window path — the shape #3650 dropped entirely. No + // granularity, or `canHandle` correctly declines to the ObjectQL strategy. + if (c.dateRange) { + it(`${c.name} — via timeDimensions.dateRange`, async () => { + expect( + await idsFor({ timeDimensions: [{ dimension: c.field, dateRange: resolveTokens(c.dateRange) }] }), + c.note, + ).toEqual([...c.expected].sort()); + }); + } + } +}); diff --git a/packages/services/service-analytics/src/strategies/filter-normalizer.ts b/packages/services/service-analytics/src/strategies/filter-normalizer.ts index e1b2dda16d..16ba8e6a8a 100644 --- a/packages/services/service-analytics/src/strategies/filter-normalizer.ts +++ b/packages/services/service-analytics/src/strategies/filter-normalizer.ts @@ -84,6 +84,37 @@ function flattenCondition(cond: Record, out: NormalizedAnalytic const opKeys = Object.keys(wrapper).filter(k => k.startsWith('$')); if (opKeys.length > 0) { for (const opKey of opKeys) { + // `$between [min, max]` LOWERS to its two bounds rather than getting a + // `between` operator of its own. Both strategies already carry the + // calendar-day whole-day rule on their upper bound — NativeSQLStrategy + // compiles a bare-day `lte` half-open (#3777), ObjectQLStrategy hands + // `$lte` to the driver, which does the same — so a range's max + // inherits that rule by construction instead of needing a second + // implementation to keep in step. (The preview evaluator's `$between` + // gap was closed the same way, sharing its `$lte` helper.) + // + // Before this, `$between` was simply absent from the operator map and + // fell to the `continue` below: the predicate VANISHED from the WHERE + // clause, so a dashboard widget carrying a range filter charted the + // entire dataset — #3650's symptom, on the surface #3650 was about. + // The temporal conformance matrix caught it as row results + // (`native-sql-temporal-conformance.test.ts`). + if (opKey === '$between') { + const v = wrapper[opKey]; + if (!Array.isArray(v) || v.length !== 2) { + // Never drop it: an unbounded read is the failure mode this whole + // branch exists to prevent, and it is indistinguishable from a + // legitimately wide query. Same stance driver-memory took for the + // same shape (#3948). + throw new Error( + `[analytics] "$between" on "${key}" needs a two-element [min, max] array, got ` + + `${JSON.stringify(v)}. Dropping the predicate would silently widen the query to every row.`, + ); + } + out.push({ member: key, operator: 'gte', values: [stringifyForCube(v[0])] }); + out.push({ member: key, operator: 'lte', values: [stringifyForCube(v[1])] }); + continue; + } const cubeOp = MONGO_TO_CUBE_OP[opKey]; if (!cubeOp) continue; const v = wrapper[opKey]; From d30f4e9e576a5fd9540642ce1c3957117aa17f2f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 10:44:14 +0000 Subject: [PATCH 3/4] docs(service-analytics): the filter normalizer states its real coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module doc described the shape it produces but never what it actually maps — and in a normalizer, that omission is load-bearing: an unmapped operator is `continue`d, which does not "not support" the predicate, it DELETES it, widening the query to rows the author excluded. That is how the $between defect survived (see #4128 for the four operators still in that state, and the #3948 precedent for making the fallback throw). Mirrors the scope comment the preview evaluator carries for the same reason. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TqqZmPS5a4gJGBoCTwipFr --- .../src/strategies/filter-normalizer.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/services/service-analytics/src/strategies/filter-normalizer.ts b/packages/services/service-analytics/src/strategies/filter-normalizer.ts index 16ba8e6a8a..a0653cc0e1 100644 --- a/packages/services/service-analytics/src/strategies/filter-normalizer.ts +++ b/packages/services/service-analytics/src/strategies/filter-normalizer.ts @@ -16,6 +16,24 @@ * Strategies stay simple — they only need to know one shape — and the * spec is honoured: dashboard metadata is authored once in the * canonical MongoDB form and the server normalizes at the boundary. + * + * # Coverage, stated honestly — an unmapped operator WIDENS the query + * + * Dropping a predicate is not "not supporting" it: the compiled SQL stays + * valid and simply returns more rows, which reads as a chart drawn over the + * whole dataset (#3650's symptom) and is invisible to any test that asserts + * the emitted SQL string. So what this maps is a capability claim: + * + * - mapped 1:1 — `$eq` `$ne` `$gt` `$gte` `$lt` `$lte` `$in` `$nin` + * `$contains` `$notContains` `$exists`, plus `null` → `notSet`; + * - lowered — `$and` (flattened in place), and `$between`, which becomes + * its two bounds so each strategy's existing upper-bound handling + * applies the calendar-day whole-day rule (see the note at the lowering); + * - NOT covered, and silently dropped today — `$startsWith` `$endsWith` + * `$null` `$regex`, and the `$or` / `$not` combinators (the latter two + * deliberately, pending recursive WHERE building). Tracked in #4128, + * which also carries the case for turning the fallback into a throw the + * way driver-memory did in #3948. */ export interface NormalizedAnalyticsFilter { From bdfa1520e370323d5a93fb344914e26ac24cb13a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 10:59:45 +0000 Subject: [PATCH 4/4] fix(service-analytics): every authorable filter operator reaches the query (#4128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the CAUSE behind the $between defect, not just that instance. `normalizeAnalyticsFilters` skipped any operator missing from its map, and a skipped predicate does not narrow a query — it widens it: the SQL stays valid and returns rows the author excluded. Four operators from the spec's authorable vocabulary sat in that state, and a fifth was mapped wrongly: - $startsWith / $endsWith were dropped. Both strategies now compile them — anchored LIKE on the raw-SQL path, the canonical operators (which every driver implements directly) on the ObjectQL path, so an anchored match does not depend on regex dialect. - $null was dropped. It is what the console emits for an "is empty" filter, so such a widget showed every row. - $exists was mapped value-INDEPENDENTLY to `set`, so {$exists: false} compiled to IS NOT NULL — the inverse of what it asks. It and $null are now resolved explicitly: a key→name map cannot express an operator whose meaning flips with its value, which is exactly how that inversion got in. - $notContains reached ObjectQLStrategy, which had no arm for it and fell to a `default` returning a bare value — compiling "does not contain x" as "equals x". - Unknown operators now THROW on both surfaces rather than being dropped (normalizer) or reinterpreted as an equality (ObjectQL). #3948's call for the same shape. $or / $not remain skipped — expressing them needs a recursive WHERE builder rather than the flat array the strategies consume. That gap is declared in the module doc rather than silent. filter-operator-coverage.test.ts runs the whole vocabulary against a real SQLite engine and asserts ROW IDS; six of its cases fail without this change. A dropped predicate is invisible to the SQL-string assertions the strategies' other suites use, which is how these survived. service-analytics 413 green. Closes #4128. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TqqZmPS5a4gJGBoCTwipFr --- .../analytics-filter-operator-coverage.md | 42 ++++ .../filter-operator-coverage.test.ts | 193 ++++++++++++++++++ .../src/strategies/filter-normalizer.ts | 77 +++++-- .../src/strategies/native-sql-strategy.ts | 15 +- .../src/strategies/objectql-strategy.ts | 18 +- 5 files changed, 326 insertions(+), 19 deletions(-) create mode 100644 .changeset/analytics-filter-operator-coverage.md create mode 100644 packages/services/service-analytics/src/__tests__/filter-operator-coverage.test.ts diff --git a/.changeset/analytics-filter-operator-coverage.md b/.changeset/analytics-filter-operator-coverage.md new file mode 100644 index 0000000000..bfcc1c0957 --- /dev/null +++ b/.changeset/analytics-filter-operator-coverage.md @@ -0,0 +1,42 @@ +--- +"@objectstack/service-analytics": patch +--- + +fix(service-analytics): every authorable filter operator now reaches the query (#4128) + +Closes the cause behind the `$between` defect rather than just that instance. +`normalizeAnalyticsFilters` skipped any operator missing from its map, and a +skipped predicate does not narrow a query — it **widens** it: the compiled SQL +stays valid and returns rows the author excluded. Four operators from the +spec's authorable vocabulary sat in that state, plus one that was mapped +incorrectly. + +- **`$startsWith` / `$endsWith`** were dropped entirely. Both strategies now + compile them — anchored `LIKE 'x%'` / `LIKE '%x'` on the raw-SQL path, and + the canonical `$startsWith` / `$endsWith` operators (which every driver + implements directly) on the ObjectQL path, so an anchored match does not + depend on regex dialect. +- **`$null`** was dropped. It is the shape the console emits for an "is empty" + / "is not empty" filter, so such a widget was showing every row. Now compiles + to `IS NULL` / `IS NOT NULL` per its boolean. +- **`$exists`** was mapped value-*independently* to `set`, so `{$exists: false}` + compiled to `IS NOT NULL` — the exact inverse of what it asks for. It and + `$null` are now resolved explicitly, because a key→name map cannot express an + operator whose meaning flips with its value. +- **`$notContains`** reached the ObjectQL strategy, which had no arm for it and + fell through to a `default` returning a bare value — compiling "does not + contain x" as "**equals** x". +- **Unknown operators now throw** on both surfaces instead of being silently + dropped (normalizer) or reinterpreted as an equality (ObjectQL strategy). An + operator outside the vocabulary is a caller error, and a loud one beats a + silently widened read — the call driver-memory made for the same shape in + #3948. + +Still declared as a gap, but no longer a silent one: `$or` / `$not` are skipped, +since expressing them needs a recursive WHERE builder rather than the flat +array the strategies consume. + +Cover is `filter-operator-coverage.test.ts`, which runs the whole vocabulary +against a real SQLite engine and asserts **row ids** — six of its cases fail +without this change. A dropped predicate is invisible to the SQL-string +assertions the strategies' other suites use, which is how these survived. diff --git a/packages/services/service-analytics/src/__tests__/filter-operator-coverage.test.ts b/packages/services/service-analytics/src/__tests__/filter-operator-coverage.test.ts new file mode 100644 index 0000000000..94098c7ee9 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/filter-operator-coverage.test.ts @@ -0,0 +1,193 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Every authorable filter operator reaches the query — the closure of #4128. + * + * `normalizeAnalyticsFilters` maps the spec's `FilterCondition` vocabulary onto + * the internal pipeline form. Whatever it fails to map used to be `continue`d, + * and that is not "unsupported": the predicate DISAPPEARS, the compiled SQL + * stays valid, and the query returns rows the author excluded. It reads as a + * chart drawn over the whole dataset (#3650's symptom) and is invisible to a + * test that asserts the emitted SQL string — which is what every other suite + * for these strategies does, and why four operators sat broken behind one that + * had already been found (`$between`, ADR-0053 D-A3.1). + * + * So this file asserts ROW IDS, against a real SQLite (`sql.js`, the pure-WASM + * engine `driver-sql` itself falls back to — see the note in + * `read-scope-sql-conformance.test.ts` for why not `better-sqlite3`). A dropped + * predicate cannot hide from it: the row set is simply wrong. + * + * The table below is the whole authorable vocabulary of `filter.zod.ts`, so a + * new operator added to the spec without a home in the pipeline fails here + * rather than silently widening a customer's dashboard. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { Cube } from '@objectstack/spec/data'; +import type { AnalyticsQuery, FilterCondition } from '@objectstack/spec/data'; +import type { StrategyContext } from '@objectstack/spec/contracts'; + +import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js'; +import { normalizeAnalyticsFilters } from '../strategies/filter-normalizer.js'; + +interface Row { + id: string; + name: string | null; + score: number; +} + +/** `n_null` carries a NULL name — the row the null predicates turn on. */ +const ROWS: Row[] = [ + { id: 'a_alpha', name: 'alpha-one', score: 10 }, + { id: 'b_alphex', name: 'alphex-two', score: 20 }, + { id: 'c_beta', name: 'beta-one', score: 30 }, + { id: 'd_null', name: null, score: 40 }, +]; + +const CUBE: Cube = { + name: 'ops', + title: 'Ops', + sql: 'ops', + measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } }, + dimensions: { + id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, + name: { name: 'name', label: 'Name', type: 'string', sql: 'name' }, + score: { name: 'score', label: 'Score', type: 'number', sql: 'score' }, + }, + public: false, +} as unknown as Cube; + +/** + * One case per operator in `filter.zod.ts`'s authorable vocabulary. + * `expected` is the row ids the filter MUST return — the full row set means + * the predicate went missing. + */ +const CASES: Array<{ op: string; filter: FilterCondition; expected: string[]; note?: string }> = [ + { op: '$eq', filter: { name: { $eq: 'alpha-one' } }, expected: ['a_alpha'] }, + { op: '$ne', filter: { name: { $ne: 'alpha-one' } }, expected: ['b_alphex', 'c_beta'] }, + { op: '$gt', filter: { score: { $gt: 20 } }, expected: ['c_beta', 'd_null'] }, + { op: '$gte', filter: { score: { $gte: 20 } }, expected: ['b_alphex', 'c_beta', 'd_null'] }, + { op: '$lt', filter: { score: { $lt: 20 } }, expected: ['a_alpha'] }, + { op: '$lte', filter: { score: { $lte: 20 } }, expected: ['a_alpha', 'b_alphex'] }, + { op: '$in', filter: { name: { $in: ['alpha-one', 'beta-one'] } }, expected: ['a_alpha', 'c_beta'] }, + { op: '$nin', filter: { name: { $nin: ['alpha-one'] } }, expected: ['b_alphex', 'c_beta'] }, + { op: '$between', filter: { score: { $between: [20, 30] } }, expected: ['b_alphex', 'c_beta'], note: '#4128: was dropped → every row.' }, + { op: '$contains', filter: { name: { $contains: 'one' } }, expected: ['a_alpha', 'c_beta'] }, + { + op: '$notContains', + filter: { name: { $notContains: 'one' } }, + expected: ['b_alphex'], + note: 'On the ObjectQL path this had no arm and fell to the default, compiling "does not contain" as an EQUALITY.', + }, + { + op: '$startsWith', + filter: { name: { $startsWith: 'alpha' } }, + expected: ['a_alpha'], + note: '#4128: was dropped → every row. b_alphex proves the anchor is a prefix, not a substring.', + }, + { + op: '$endsWith', + filter: { name: { $endsWith: '-one' } }, + expected: ['a_alpha', 'c_beta'], + note: '#4128: was dropped → every row.', + }, + { + op: '$null: true', + filter: { name: { $null: true } }, + expected: ['d_null'], + note: '#4128: was dropped → every row. This is the shape the console emits for an "is empty" filter.', + }, + { op: '$null: false', filter: { name: { $null: false } }, expected: ['a_alpha', 'b_alphex', 'c_beta'] }, + { op: '$exists: true', filter: { name: { $exists: true } }, expected: ['a_alpha', 'b_alphex', 'c_beta'] }, + { + op: '$exists: false', + filter: { name: { $exists: false } }, + expected: ['d_null'], + note: 'Was mapped value-INDEPENDENTLY to `set`, so it compiled to IS NOT NULL — the exact inverse of what it asks.', + }, + { op: 'implicit equality', filter: { name: 'beta-one' }, expected: ['c_beta'] }, + { op: 'bare null', filter: { name: null }, expected: ['d_null'] }, + { + op: '$and', + filter: { $and: [{ score: { $gte: 20 } }, { name: { $contains: 'one' } }] }, + expected: ['c_beta'], + }, +]; + +/** Point sql.js at the `.wasm` shipped inside its own package (Node-safe). */ +async function locateWasm(): Promise<((file: string) => string) | undefined> { + try { + const { createRequire } = await import('node:module'); + const require = createRequire(import.meta.url); + const pkgJsonPath = require.resolve('sql.js/package.json'); + const { dirname, join } = await import('node:path'); + const dir = dirname(pkgJsonPath); + return (file: string) => join(dir, 'dist', file); + } catch { + return undefined; + } +} + +describe('analytics filters — every authorable operator reaches the query (#4128)', () => { + let db: any; + let ctx: StrategyContext; + + beforeAll(async () => { + const mod: any = await import('sql.js'); + const initSqlJs = mod.default ?? mod; + const locateFile = await locateWasm(); + const SQL = await initSqlJs(locateFile ? { locateFile } : undefined); + + db = new SQL.Database(); + db.run(`CREATE TABLE "ops" ("id" TEXT PRIMARY KEY, "name" TEXT, "score" INTEGER);`); + const insert = db.prepare(`INSERT INTO "ops" ("id","name","score") VALUES (?,?,?)`); + for (const r of ROWS) insert.run([r.id, r.name, r.score]); + insert.free(); + + ctx = { + getCube: (name: string) => (name === 'ops' ? CUBE : undefined), + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async (_object: string, sql: string, params: unknown[]) => { + const stmt = db.prepare(sql.replace(/\$\d+/g, '?')); + stmt.bind(params as any[]); + const out: Record[] = []; + while (stmt.step()) out.push(stmt.getAsObject()); + stmt.free(); + return out; + }, + } as StrategyContext; + }); + + afterAll(() => { + db?.close(); + }); + + for (const c of CASES) { + it(`${c.op} narrows to the rows it names`, async () => { + const result = await new NativeSQLStrategy().execute( + { cube: 'ops', measures: ['total'], dimensions: ['id'], where: c.filter } as AnalyticsQuery, + ctx, + ); + const got = result.rows.map((r) => String(r.id)).sort(); + expect(got, c.note).toEqual(c.expected); + // Belt and braces: a predicate that silently vanished returns the whole + // fixture, which is the one wrong answer that looks like a working query. + expect(got.length, `${c.op} matched every row — the predicate was dropped`).toBeLessThan(ROWS.length); + }); + } + + it('an operator outside the vocabulary throws instead of widening the query', () => { + // The failure mode this whole file exists to prevent: silently returning + // rows the filter excludes. A typo'd or non-spec operator is a caller + // error, and a loud one — the same call driver-memory made in #3948. + expect(() => + normalizeAnalyticsFilters({ where: { name: { $sortOf: 'alpha' } } }), + ).toThrow(/Unsupported filter operator "\$sortOf"/); + }); + + it('a malformed $between throws rather than binding a half-open guess', () => { + expect(() => normalizeAnalyticsFilters({ where: { score: { $between: [10] } } })).toThrow( + /two-element/, + ); + }); +}); diff --git a/packages/services/service-analytics/src/strategies/filter-normalizer.ts b/packages/services/service-analytics/src/strategies/filter-normalizer.ts index a0653cc0e1..12872dd32e 100644 --- a/packages/services/service-analytics/src/strategies/filter-normalizer.ts +++ b/packages/services/service-analytics/src/strategies/filter-normalizer.ts @@ -17,23 +17,31 @@ * spec is honoured: dashboard metadata is authored once in the * canonical MongoDB form and the server normalizes at the boundary. * - * # Coverage, stated honestly — an unmapped operator WIDENS the query + * # Coverage — a dropped predicate WIDENS the query, so nothing is dropped * - * Dropping a predicate is not "not supporting" it: the compiled SQL stays - * valid and simply returns more rows, which reads as a chart drawn over the - * whole dataset (#3650's symptom) and is invisible to any test that asserts - * the emitted SQL string. So what this maps is a capability claim: + * Failing to map an operator is not "not supporting" it: the predicate simply + * disappears, the compiled SQL stays valid, and the query returns rows the + * author excluded. It reads as a chart drawn over the whole dataset (#3650's + * symptom) and is invisible to any test that asserts the emitted SQL string. + * `$between`, `$startsWith`, `$endsWith` and `$null` each sat broken that way + * (#4128), so what this maps is now a complete capability claim over + * `filter.zod.ts`'s authorable vocabulary: * * - mapped 1:1 — `$eq` `$ne` `$gt` `$gte` `$lt` `$lte` `$in` `$nin` - * `$contains` `$notContains` `$exists`, plus `null` → `notSet`; - * - lowered — `$and` (flattened in place), and `$between`, which becomes - * its two bounds so each strategy's existing upper-bound handling - * applies the calendar-day whole-day rule (see the note at the lowering); - * - NOT covered, and silently dropped today — `$startsWith` `$endsWith` - * `$null` `$regex`, and the `$or` / `$not` combinators (the latter two - * deliberately, pending recursive WHERE building). Tracked in #4128, - * which also carries the case for turning the fallback into a throw the - * way driver-memory did in #3948. + * `$contains` `$notContains` `$startsWith` `$endsWith`; + * - value-DEPENDENT, so resolved explicitly rather than through the map — + * `$null` and `$exists`, whose meaning flips with their boolean; + * - lowered — `$and` (flattened in place), and `$between`, which becomes its + * two bounds so each strategy's existing upper-bound handling applies the + * calendar-day whole-day rule (see the note at the lowering); + * - anything else THROWS. An operator outside the vocabulary is a caller + * error, and a loud one beats a silently widened read — the call + * driver-memory made for the same shape in #3948. + * + * The one remaining gap is declared, not silent: the `$or` / `$not` + * combinators are still skipped, because expressing them needs a recursive + * WHERE builder rather than this flat array. Row-result cover for everything + * above lives in `filter-operator-coverage.test.ts`. */ export interface NormalizedAnalyticsFilter { @@ -42,6 +50,14 @@ export interface NormalizedAnalyticsFilter { values: string[]; } +/** + * The value-INDEPENDENT operators: the pipeline name depends only on the key. + * + * `$null` and `$exists` are deliberately absent — their meaning flips with + * their boolean value, which a key→name map cannot express. Putting `$exists` + * here anyway is what made `{$exists: false}` compile to `IS NOT NULL`, the + * exact inverse of what it asks for; both are handled explicitly below. + */ const MONGO_TO_CUBE_OP: Record = { $eq: 'equals', $ne: 'notEquals', @@ -53,7 +69,8 @@ const MONGO_TO_CUBE_OP: Record = { $nin: 'notIn', $contains: 'contains', $notContains: 'notContains', - $exists: 'set', + $startsWith: 'startsWith', + $endsWith: 'endsWith', }; /** @@ -133,8 +150,36 @@ function flattenCondition(cond: Record, out: NormalizedAnalytic out.push({ member: key, operator: 'lte', values: [stringifyForCube(v[1])] }); continue; } + + // The two null predicates read their BOOLEAN, not just their key — + // which is why neither can live in MONGO_TO_CUBE_OP. `$null: true` + // asks for IS NULL (`notSet`), `$null: false` for IS NOT NULL + // (`set`); `$exists` is the mirror image. `$null` is the shape the + // console emits for an "is empty" / "is not empty" filter + // (`is_null`/`is_not_null` normalise to it in `filter.zod.ts`), so + // dropping it silently meant such a widget showed every row. + if (opKey === '$null' || opKey === '$exists') { + const isNull = opKey === '$null' ? wrapper[opKey] === true : wrapper[opKey] === false; + out.push({ member: key, operator: isNull ? 'notSet' : 'set', values: [] }); + continue; + } + const cubeOp = MONGO_TO_CUBE_OP[opKey]; - if (!cubeOp) continue; + if (!cubeOp) { + // NEVER drop: a missing predicate does not narrow the query, it + // WIDENS it — the compiled SQL stays valid and simply returns rows + // the author excluded, which is indistinguishable from a + // legitimately broad query and invisible to any test that asserts + // the emitted SQL. That failure mode is #3650's, and skipping + // unmapped operators is how `$between` reproduced it (#4128). + // driver-memory made the same call for the same reason in #3948. + throw new Error( + `[analytics] Unsupported filter operator "${opKey}" on "${key}". ` + + `Supported: ${Object.keys(MONGO_TO_CUBE_OP).join(', ')}, $between, $null, $exists ` + + `(and $and; $or/$not are not yet compiled by the analytics strategies). ` + + `Dropping it would silently widen the query to rows the filter excludes.`, + ); + } const v = wrapper[opKey]; const values = Array.isArray(v) ? v.map(stringifyForCube) diff --git a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts index 8b86d78b10..3b4cb0b05b 100644 --- a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts @@ -484,6 +484,14 @@ export class NativeSQLStrategy implements AnalyticsStrategy { const opMap: Record = { equals: '=', notEquals: '!=', gt: '>', gte: '>=', lt: '<', lte: '<=', contains: 'LIKE', notContains: 'NOT LIKE', + startsWith: 'LIKE', endsWith: 'LIKE', + }; + /** The LIKE pattern each string operator wraps its comparand in. */ + const likePattern: Record string> = { + contains: (v) => `%${v}%`, + notContains: (v) => `%${v}%`, + startsWith: (v) => `${v}%`, + endsWith: (v) => `%${v}`, }; // Null predicates and the LIKE family read the column as stored — the former @@ -504,8 +512,11 @@ export class NativeSQLStrategy implements AnalyticsStrategy { const sqlOp = opMap[operator]; if (!sqlOp || !values || values.length === 0) return null; - if (operator === 'contains' || operator === 'notContains') { - params.push(`%${values[0]}%`); + // The LIKE family reads the column as stored — a substring/prefix/suffix + // match is on the raw text — so it keeps the un-normalised reference. + const pattern = likePattern[operator]; + if (pattern) { + params.push(pattern(values[0])); return `${rawCol} ${sqlOp} $${params.length}`; } diff --git a/packages/services/service-analytics/src/strategies/objectql-strategy.ts b/packages/services/service-analytics/src/strategies/objectql-strategy.ts index 9b2c8955eb..5c21747488 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -820,9 +820,25 @@ export class ObjectQLStrategy implements AnalyticsStrategy { case 'lt': return { $lt: v0 }; case 'lte': return { $lte: v0 }; case 'contains': return { $regex: values[0] }; + // `notContains` had no arm and fell to the `default` below, which returns + // a BARE VALUE — i.e. `{field: 'x'}`, an equality. "does not contain x" + // was compiled as "equals x". These three pass through as the canonical + // spec operators every driver implements directly, so an anchored match + // stays anchored rather than depending on regex dialect (#4128). + case 'notContains': return { $notContains: values[0] }; + case 'startsWith': return { $startsWith: values[0] }; + case 'endsWith': return { $endsWith: values[0] }; case 'in': return { $in: all }; case 'notIn': return { $nin: all }; - default: return v0; + default: + // Was `return v0` — a silent reinterpretation of the operator as an + // equality, the write-side twin of the normalizer's dropped predicate + // (#4128). Every operator `normalizeAnalyticsFilters` can emit is + // handled above, so reaching here means the two drifted apart. + throw new Error( + `[analytics] ObjectQL strategy cannot express filter operator "${operator}". ` + + `Treating it as an equality would silently query something the author did not ask for.`, + ); } }