Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/temporal-conformance-matrix.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
65 changes: 65 additions & 0 deletions docs/adr/0053-date-and-datetime-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
29 changes: 29 additions & 0 deletions packages/formula/src/matches-filter-temporal-conformance.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
});
Original file line number Diff line number Diff line change
@@ -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());
});
}
});
Original file line number Diff line number Diff line change
@@ -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());
});
}
});
Original file line number Diff line number Diff line change
@@ -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());
});
}
});
Original file line number Diff line number Diff line change
@@ -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);
});
}
});
Loading
Loading