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
45 changes: 45 additions & 0 deletions .changeset/temporal-time-axis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
"@objectstack/spec": minor
"@objectstack/driver-memory": patch
"@objectstack/driver-mongodb": patch
---

feat(spec,drivers): the temporal conformance matrix gains its `Field.time` axis — and `time` finally gets a storage form off SQL (ADR-0053 D-A3.2)

`@objectstack/spec/data` gains `TEMPORAL_TIME_ROWS` / `TEMPORAL_TIME_CASES`,
the wall-clock half of the shared matrix. A time gets its own table rather than
a third `kind` on the existing one because it shares no comparand vocabulary
with the other two: no relative token resolves to a wall clock, and the
bare-day whole-day rule (#3777) must **not** reach it — which the table now
asserts rather than assumes, since "the rule leaked into the wrong field type"
is exactly what a conformance matrix is for. The fixture is a business day
carrying the boundaries #3994 measured: both window edges, the pair straddling
the millisecond-suffix width change, midnight and `23:59:59.999`.

**The axis found a real gap on its first run.** ADR-0053 D-C gave `Field.time`
a canonical form on every SQL dialect, but `driver-memory` and
`driver-mongodb` were never extended — both declared
`TemporalFieldKind = 'datetime' | 'date'`, so a `time` column was never
classified and never coerced. It therefore held whatever each writer produced,
and both stores compare across types by bracket: a text bound matched no
`Date`-written row, in either direction, for every operator. Measured on
`driver-memory`, **8 of the 9 shared cases** returned only the text-written
half — a business-hours window answering `[d_mid, f_close]` instead of
`[c_open, d_mid, e_mid_ms, f_close]`. This is #4047's failure one field type
over, and it survived #4047 because that work extended `datetime` and `date`
without revisiting `time`. On mongo it was also a documentation failure: that
module's canon table has listed `time` as `HH:MM:SS[.fff]` text since #3994,
and nothing implemented it.

Both drivers now carry `storageTimeValue`, mirroring the SQL
`canonicalTimeOfDay`: `HH:MM:SS`, `.fff` only when the milliseconds are
non-zero, a `Date` / epoch / full-timestamp folding to its **UTC** time-of-day
(never the host's), and totality — an out-of-range wall clock like `'25:00'`
passes through rather than being silently rewritten. Text on both, mongo
included: a wall clock is not an instant, so a BSON `Date` would invent a
calendar day and a zone the author never wrote.

If you have existing `time` data on either driver, values written as `Date`
objects converge to canonical text on their next write; reads of un-migrated
documents are unchanged. Filters were already unable to reach the mixed half,
so no query that worked before stops working.
67 changes: 67 additions & 0 deletions docs/adr/0053-date-and-datetime-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -911,3 +911,70 @@ Two things, which is the argument for having built it:
(#1874's original shape), a `driver-sqlite-wasm` consumer pinning the
inheritance seam, and a legacy-storage sweep in the `driver-sql` consumer so
the un-backfilled read-repair path answers the same table.

---

## Addendum — D-A3.2: the `Field.time` axis, and the two backends that had no time convention at all

> **Status:** landed. Extends the D-A3 matrix to the third temporal field type,
> and closes the gap that extension measured.

### The axis

`TEMPORAL_TIME_ROWS` / `TEMPORAL_TIME_CASES` sit beside the existing table. A
wall clock gets its **own** table rather than a third `kind` on the first,
because it shares no comparand vocabulary with the other two: no relative token
resolves to a time (`{today}` is a calendar day), and the bare-day whole-day
rule (D-D) must NOT reach it. That last point is asserted rather than assumed —
`nextUtcCalendarDay` widens only a `YYYY-MM-DD` string, and "the rule leaked
into the wrong field type" is exactly the class of defect this matrix exists to
catch, so an inclusive time upper bound is pinned as EXACT.

The fixture is a business day whose boundaries are the ones #3994 measured:
both window edges, the two rows straddling the millisecond-suffix width change
(`14:30:00` and `14:30:00.500`), midnight, and `23:59:59.999`. Same scope rule
as the parent table — a minutes-only comparand (`'14:30'`) is canonicalised by
a typed backend and compared raw by a type-blind one, so that cell is
schema-aware-only and stays in the typed drivers' own suites, the exact twin of
the `$gt`-on-`datetime` limit above.

### What the axis found: D-C never reached the non-SQL drivers

D-C gave `Field.time` a canonical form on every SQL dialect. `driver-memory`
and `driver-mongodb` were never extended: both declared
`TemporalFieldKind = 'datetime' | 'date'`, so `indexTemporalFields` never
classified a time column and `coerceTemporalValue` never touched one. A `time`
field kept whatever each writer produced — and mingo (memory) and BSON (mongo)
both compare across types by bracket, so a text bound matched no `Date`-written
row in either direction, for every operator.

Measured on `driver-memory`: **8 of the 9 shared time cases** returned only the
text-written half of the fixture. A business-hours window answered
`[d_mid, f_close]` instead of `[c_open, d_mid, e_mid_ms, f_close]`. This is
#4047's failure, one field type over, and it survived #4047 precisely because
that work extended `datetime` and `date` without revisiting `time`.

Worth recording as a documentation failure too: `mongodb-temporal.ts`'s own
canon table has listed `time` as `HH:MM:SS[.fff]` text since #3994. The
document was right and the code never implemented it — a declared-but-unenforced
claim of the kind Prime Directive #10 is about, invisible until something
executed it.

### The resolution

`storageTimeValue` in each driver, mirroring `canonicalTimeOfDay`: `HH:MM:SS`,
`.fff` only when non-zero, a `Date`/epoch/full-timestamp folding to its **UTC**
time-of-day, and totality (an out-of-range wall clock passes through rather
than being rewritten). Text on both, including mongo — a wall clock is not an
instant, so a BSON `Date` would invent a calendar day and a zone the author
never wrote, the same reasoning that keeps `date` as text there.

The variable width is what makes text storage correct rather than merely
convenient: `.` sorts below every digit, so lexicographic order — which is
exactly what mingo performs and what a mongo text range uses — stays
chronological across both widths.

Coverage note: the mongo end-to-end sweep needs a real server
(`mongodb-memory-server`), so it runs in CI and skips where no binary is
available. The conversion itself is pinned by `mongodb-time-storage.test.ts`,
which is pure and runs everywhere.
23 changes: 22 additions & 1 deletion packages/formula/src/matches-filter-temporal-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@
*/

import { describe, it, expect } from 'vitest';
import { TEMPORAL_CASES, TEMPORAL_ROWS } from '@objectstack/spec/data';
import {
TEMPORAL_CASES,
TEMPORAL_ROWS,
TEMPORAL_TIME_CASES,
TEMPORAL_TIME_ROWS,
} from '@objectstack/spec/data';

import { matchesFilterCondition } from './matches-filter';

Expand Down Expand Up @@ -48,4 +53,20 @@ describe('matchesFilterCondition — temporal conformance', () => {
* cannot be handed. If that ever changes — if a `check` gains a token-bearing
* filter form — this comment is the thing to delete, and the axis is already
* sitting in the shared table ready to be consumed.
*
* The same reasoning covers the wall-clock sweep below: `TemporalTimeCase`
* carries no token spelling at all, because no relative-date macro resolves to
* a time of day.
*/

describe('matchesFilterCondition — Field.time conformance', () => {
// Type-blind, so the records carry the canonical wall-clock text a converged
// column presents. That the variable-width canon still orders correctly under
// a plain string comparison is the assertion.
for (const c of TEMPORAL_TIME_CASES) {
it(c.name, () => {
const got = TEMPORAL_TIME_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
Expand Up @@ -17,7 +17,13 @@
*/

import { describe, it, expect, beforeAll } from 'vitest';
import { TEMPORAL_CASES, TEMPORAL_NOW, TEMPORAL_ROWS } from '@objectstack/spec/data';
import {
TEMPORAL_CASES,
TEMPORAL_NOW,
TEMPORAL_ROWS,
TEMPORAL_TIME_CASES,
TEMPORAL_TIME_ROWS,
} from '@objectstack/spec/data';
import { resolveFilterTokens } from '@objectstack/core';
import { InMemoryDriver } from './memory-driver.js';

Expand Down Expand Up @@ -63,3 +69,33 @@ describe('driver-memory — temporal conformance', () => {
}
}
});

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

beforeAll(async () => {
driver = new InMemoryDriver({});
await driver.connect();
await driver.syncSchema('time_conformance', {
name: 'time_conformance',
fields: { at: { type: 'time' }, why: { type: 'string' } },
});
for (const r of TEMPORAL_TIME_ROWS) {
await driver.create('time_conformance', {
id: r.id,
// The mixed-writer axis (D-E4) for wall clocks: a `Date` write and a
// canonical-text write of the SAME wall clock must converge.
at: r.writerForm === 'native' ? new Date(`1970-01-01T${r.at}Z`) : r.at,
why: r.why,
});
}
});

for (const c of TEMPORAL_TIME_CASES) {
it(c.name, async () => {
const rows = await driver.find('time_conformance', { where: c.filter } as any);
const got = (rows as any[]).map((r) => r.id).sort();
expect(got, c.note).toEqual([...c.expected].sort());
});
}
});
56 changes: 54 additions & 2 deletions packages/plugins/driver-memory/src/memory-temporal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
* |---|---|---|
* | `datetime` | canonical UTC ISO text (`…T…Z`, ms precision) | this store has no native instant type; ISO-8601 UTC sorts chronologically under the plain string comparison mingo performs, and it is the wire form, so it survives JSON persistence unchanged. |
* | `date` | `YYYY-MM-DD` text | timezone-naive by ADR-0053 Phase 1 — an instant would re-couple it to a zone. |
* | `time` | `HH:MM:SS`, `.fff` only when non-zero | a timezone-naive wall clock (ADR-0053 D-C1); the variable width still sorts chronologically because `.` sorts below every digit. |
*
* The rule is applied on write ({@link toStorageForms}) and to filter
* comparands, which is the pairing that keeps the two sides from disagreeing.
Expand Down Expand Up @@ -89,8 +90,56 @@ export function storageDateValue(value: unknown): unknown {
return value;
}

/**
* Collapse a `Field.time` value to a canonical timezone-naive wall clock —
* `HH:MM:SS`, extended to `HH:MM:SS.fff` exactly when the milliseconds are
* non-zero. Mirrors `SqlDriver`'s `canonicalTimeOfDay` (ADR-0053 D-C1), so a
* fixture that moves between the two backends compares identically.
*
* This driver had no time rule at all, which is the same meta-problem #3994
* found on SQL and #4047 found here for `datetime`: with nothing normalising
* the column, a `Date` write and a `'09:00:00'` write sat side by side, and
* mingo's cross-type comparison meant a text bound matched no `Date` row in
* either direction. Measured: 8 of the 9 shared time cases returned only the
* text-written half of the fixture.
*
* Why variable width rather than a fixed `.000`: `.` sorts below every digit,
* so lexicographic order — which is exactly what mingo performs on strings —
* stays chronological across the two widths (`'14:30:00.100' < '14:30:01'`),
* and the zero-millisecond spelling stays the `HH:MM:SS` every dialect's
* native TIME emits.
*
* A `Date` / epoch-ms / full-timestamp value folds to its **UTC** time-of-day,
* never the host's, matching the platform's instant semantics everywhere else.
* Total: an out-of-range wall clock (`'25:00'`) or unparseable junk passes
* through untouched rather than being silently rewritten.
*/
export function storageTimeValue(value: unknown): unknown {
if (value == null) return value;
if (typeof value === 'string') {
const s = value.trim();
if (s === '') return value;
const m = /^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?$/.exec(s);
if (m) {
const [, hh, mm, ss = '00', frac] = m;
if (Number(hh) > 23 || Number(mm) > 59 || Number(ss) > 59) return value;
const ms = frac ? `${frac}000`.slice(0, 3) : '000';
return ms === '000' ? `${hh}:${mm}:${ss}` : `${hh}:${mm}:${ss}.${ms}`;
}
}
// Not a bare wall clock — a `Date`, epoch ms, or a full/zone-naive timestamp
// string. Delegate to the one function that owns instants, then keep its UTC
// time-of-day. Same delegation `canonicalTimeOfDay` performs.
const instant = storageDatetimeValue(value);
if (typeof instant === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(instant)) {
const time = instant.slice(11, 23);
return time.endsWith('.000') ? time.slice(0, 8) : time;
}
return value;
}

/** Which temporal rule a declared field takes, if any. */
export type TemporalFieldKind = 'datetime' | 'date';
export type TemporalFieldKind = 'datetime' | 'date' | 'time';

/**
* Put a value into the storage form of a field of `kind`. `undefined` kind —
Expand All @@ -100,7 +149,9 @@ export type TemporalFieldKind = 'datetime' | 'date';
export function coerceTemporalValue(value: unknown, kind: TemporalFieldKind | undefined): unknown {
if (kind === undefined) return value;
if (Array.isArray(value)) return value.map((v) => coerceTemporalValue(v, kind));
return kind === 'datetime' ? storageDatetimeValue(value) : storageDateValue(value);
if (kind === 'datetime') return storageDatetimeValue(value);
if (kind === 'time') return storageTimeValue(value);
return storageDateValue(value);
}

/**
Expand All @@ -114,6 +165,7 @@ export function indexTemporalFields(
for (const [name, def] of Object.entries(fields ?? {})) {
if (def?.type === 'datetime') out.set(name, 'datetime');
else if (def?.type === 'date') out.set(name, 'date');
else if (def?.type === 'time') out.set(name, 'time');
}
return out;
}
12 changes: 10 additions & 2 deletions packages/plugins/driver-mongodb/src/mongodb-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@

import type { Filter } from 'mongodb';
import { nextUtcCalendarDay } from '@objectstack/core';
import { coerceTemporalValue, type TemporalFieldKindResolver } from './mongodb-temporal.js';
import {
coerceTemporalValue,
type TemporalFieldKind,
type TemporalFieldKindResolver,
} from './mongodb-temporal.js';

/**
* Translate an ObjectStack `where` clause into a MongoDB filter document.
Expand Down Expand Up @@ -132,7 +136,11 @@ function translateCondition(
*/
function translateFieldOperators(
ops: Record<string, unknown>,
kind?: 'datetime' | 'date',
// The shared type, not a hand-copy of its members. This signature spelled
// the union out literally, so widening the canon to include `time`
// (ADR-0053 D-C1) left the two out of step and the call site stopped
// compiling. One definition means the next temporal type is added once.
kind?: TemporalFieldKind,
): Record<string, unknown> {
const result: Record<string, unknown> = {};
const store = (v: unknown) => coerceTemporalValue(v, kind);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { TEMPORAL_CASES, TEMPORAL_NOW, TEMPORAL_ROWS } from '@objectstack/spec/data';
import {
TEMPORAL_CASES,
TEMPORAL_NOW,
TEMPORAL_ROWS,
TEMPORAL_TIME_CASES,
TEMPORAL_TIME_ROWS,
} from '@objectstack/spec/data';
import { resolveFilterTokens } from '@objectstack/core';
import { MongoDBDriver } from './mongodb-driver.js';

Expand Down Expand Up @@ -61,9 +67,12 @@ describe.skipIf(!sharedMongod)('driver-mongodb — temporal conformance', () =>
}
}, 90_000);

// Only this suite's own connection — the SERVER is shared with the time
// suite below and is stopped once, at file level, after both have run.
// Stopping it here left `getUri()` throwing `Incorrect State … gotState:
// 'new'` for the second suite, whose cases then never executed at all.
afterAll(async () => {
if (driver) await driver.disconnect();
if (mongod) await mongod.stop();
});

for (const c of TEMPORAL_CASES) {
Expand All @@ -82,3 +91,45 @@ describe.skipIf(!sharedMongod)('driver-mongodb — temporal conformance', () =>
}
}
});

describe.skipIf(!sharedMongod)('driver-mongodb — Field.time conformance', () => {
const mongod = sharedMongod as MongoMemoryServer;
let driver: MongoDBDriver;

beforeAll(async () => {
driver = new MongoDBDriver({ url: mongod.getUri(), database: 'time_conformance' });
await driver.connect();
await driver.syncSchema('time_conformance', {
name: 'time_conformance',
fields: { at: { type: 'time' }, why: { type: 'string' } },
});
for (const r of TEMPORAL_TIME_ROWS) {
await driver.create('time_conformance', {
id: r.id,
// The mixed-writer axis for wall clocks: a `Date` write and a
// canonical-text write of the SAME wall clock must converge, or BSON
// type-bracket comparison hides one half from every text bound.
at: r.writerForm === 'native' ? new Date(`1970-01-01T${r.at}Z`) : r.at,
why: r.why,
});
}
}, 90_000);

afterAll(async () => {
if (driver) await driver.disconnect();
});

for (const c of TEMPORAL_TIME_CASES) {
it(c.name, async () => {
const rows = await driver.find('time_conformance', { where: c.filter } as any);
const got = (rows as any[]).map((r) => r.id).sort();
expect(got, c.note).toEqual([...c.expected].sort());
});
}
});

// The shared server outlives both suites, so it is torn down once here rather
// than by whichever suite happens to finish first.
afterAll(async () => {
if (sharedMongod) await sharedMongod.stop();
});
Loading
Loading