Skip to content

Commit 6038de7

Browse files
authored
feat(spec,drivers): the temporal matrix gains its Field.time axis, and time gets a storage form off SQL (ADR-0053 D-A3.2) (#4147)
The wall-clock half of the shared conformance matrix, plus the gap it measured on its first run. TEMPORAL_TIME_ROWS / TEMPORAL_TIME_CASES get their OWN table rather than a third `kind` on the existing one: a time 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. "The rule leaked into the wrong field type" is precisely the class of defect a conformance matrix exists to catch. The fixture is a business day carrying the boundaries #3994 measured: both window edges, the pair straddling the millisecond-suffix width change, midnight, 23:59:59.999. What it found: 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 indexTemporalFields never classified a time column and coerceTemporalValue never touched one — the column kept whatever each writer produced, and mingo and BSON 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 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]. That 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 canonicalTimeOfDay: HH:MM:SS with .fff only when non-zero, a Date/epoch/full-timestamp folding to its UTC time-of-day (never the host's), and totality — '25:00' passes through rather than being 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. The variable width is what makes text storage correct rather than merely convenient — `.` sorts below every digit, so lexicographic order stays chronological across both widths. Two supporting fixes the work surfaced, both instances of one theme — the same knowledge kept in two copies: - mongodb-filter.ts spelled the temporal-kind union out by hand instead of importing it, so widening the canon left the call site uncompilable. It now points at the one definition. - The Field.time suite appended to mongodb-temporal-conformance.test.ts never ran: the datetime suite's afterAll stopped the SHARED MongoMemoryServer, so the time suite's beforeAll called getUri() on a stopped instance. Its 9 cases reported as skipped — indistinguishable from the "no binary available" skip. The server teardown moves to file level. Refs #4081.
1 parent a9801af commit 6038de7

14 files changed

Lines changed: 751 additions & 13 deletions

.changeset/temporal-time-axis.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/driver-memory": patch
4+
"@objectstack/driver-mongodb": patch
5+
---
6+
7+
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)
8+
9+
`@objectstack/spec/data` gains `TEMPORAL_TIME_ROWS` / `TEMPORAL_TIME_CASES`,
10+
the wall-clock half of the shared matrix. A time gets its own table rather than
11+
a third `kind` on the existing one because it shares no comparand vocabulary
12+
with the other two: no relative token resolves to a wall clock, and the
13+
bare-day whole-day rule (#3777) must **not** reach it — which the table now
14+
asserts rather than assumes, since "the rule leaked into the wrong field type"
15+
is exactly what a conformance matrix is for. The fixture is a business day
16+
carrying the boundaries #3994 measured: both window edges, the pair straddling
17+
the millisecond-suffix width change, midnight and `23:59:59.999`.
18+
19+
**The axis found a real gap on its first run.** ADR-0053 D-C gave `Field.time`
20+
a canonical form on every SQL dialect, but `driver-memory` and
21+
`driver-mongodb` were never extended — both declared
22+
`TemporalFieldKind = 'datetime' | 'date'`, so a `time` column was never
23+
classified and never coerced. It therefore held whatever each writer produced,
24+
and both stores compare across types by bracket: a text bound matched no
25+
`Date`-written row, in either direction, for every operator. Measured on
26+
`driver-memory`, **8 of the 9 shared cases** returned only the text-written
27+
half — a business-hours window answering `[d_mid, f_close]` instead of
28+
`[c_open, d_mid, e_mid_ms, f_close]`. This is #4047's failure one field type
29+
over, and it survived #4047 because that work extended `datetime` and `date`
30+
without revisiting `time`. On mongo it was also a documentation failure: that
31+
module's canon table has listed `time` as `HH:MM:SS[.fff]` text since #3994,
32+
and nothing implemented it.
33+
34+
Both drivers now carry `storageTimeValue`, mirroring the SQL
35+
`canonicalTimeOfDay`: `HH:MM:SS`, `.fff` only when the milliseconds are
36+
non-zero, a `Date` / epoch / full-timestamp folding to its **UTC** time-of-day
37+
(never the host's), and totality — an out-of-range wall clock like `'25:00'`
38+
passes through rather than being silently rewritten. Text on both, mongo
39+
included: a wall clock is not an instant, so a BSON `Date` would invent a
40+
calendar day and a zone the author never wrote.
41+
42+
If you have existing `time` data on either driver, values written as `Date`
43+
objects converge to canonical text on their next write; reads of un-migrated
44+
documents are unchanged. Filters were already unable to reach the mixed half,
45+
so no query that worked before stops working.

docs/adr/0053-date-and-datetime-semantics.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -911,3 +911,70 @@ Two things, which is the argument for having built it:
911911
(#1874's original shape), a `driver-sqlite-wasm` consumer pinning the
912912
inheritance seam, and a legacy-storage sweep in the `driver-sql` consumer so
913913
the un-backfilled read-repair path answers the same table.
914+
915+
---
916+
917+
## Addendum — D-A3.2: the `Field.time` axis, and the two backends that had no time convention at all
918+
919+
> **Status:** landed. Extends the D-A3 matrix to the third temporal field type,
920+
> and closes the gap that extension measured.
921+
922+
### The axis
923+
924+
`TEMPORAL_TIME_ROWS` / `TEMPORAL_TIME_CASES` sit beside the existing table. A
925+
wall clock gets its **own** table rather than a third `kind` on the first,
926+
because it shares no comparand vocabulary with the other two: no relative token
927+
resolves to a time (`{today}` is a calendar day), and the bare-day whole-day
928+
rule (D-D) must NOT reach it. That last point is asserted rather than assumed —
929+
`nextUtcCalendarDay` widens only a `YYYY-MM-DD` string, and "the rule leaked
930+
into the wrong field type" is exactly the class of defect this matrix exists to
931+
catch, so an inclusive time upper bound is pinned as EXACT.
932+
933+
The fixture is a business day whose boundaries are the ones #3994 measured:
934+
both window edges, the two rows straddling the millisecond-suffix width change
935+
(`14:30:00` and `14:30:00.500`), midnight, and `23:59:59.999`. Same scope rule
936+
as the parent table — a minutes-only comparand (`'14:30'`) is canonicalised by
937+
a typed backend and compared raw by a type-blind one, so that cell is
938+
schema-aware-only and stays in the typed drivers' own suites, the exact twin of
939+
the `$gt`-on-`datetime` limit above.
940+
941+
### What the axis found: D-C never reached the non-SQL drivers
942+
943+
D-C gave `Field.time` a canonical form on every SQL dialect. `driver-memory`
944+
and `driver-mongodb` were never extended: both declared
945+
`TemporalFieldKind = 'datetime' | 'date'`, so `indexTemporalFields` never
946+
classified a time column and `coerceTemporalValue` never touched one. A `time`
947+
field kept whatever each writer produced — and mingo (memory) and BSON (mongo)
948+
both compare across types by bracket, so a text bound matched no `Date`-written
949+
row in either direction, for every operator.
950+
951+
Measured on `driver-memory`: **8 of the 9 shared time cases** returned only the
952+
text-written half of the fixture. A business-hours window answered
953+
`[d_mid, f_close]` instead of `[c_open, d_mid, e_mid_ms, f_close]`. This is
954+
#4047's failure, one field type over, and it survived #4047 precisely because
955+
that work extended `datetime` and `date` without revisiting `time`.
956+
957+
Worth recording as a documentation failure too: `mongodb-temporal.ts`'s own
958+
canon table has listed `time` as `HH:MM:SS[.fff]` text since #3994. The
959+
document was right and the code never implemented it — a declared-but-unenforced
960+
claim of the kind Prime Directive #10 is about, invisible until something
961+
executed it.
962+
963+
### The resolution
964+
965+
`storageTimeValue` in each driver, mirroring `canonicalTimeOfDay`: `HH:MM:SS`,
966+
`.fff` only when non-zero, a `Date`/epoch/full-timestamp folding to its **UTC**
967+
time-of-day, and totality (an out-of-range wall clock passes through rather
968+
than being rewritten). Text on both, including mongo — a wall clock is not an
969+
instant, so a BSON `Date` would invent a calendar day and a zone the author
970+
never wrote, the same reasoning that keeps `date` as text there.
971+
972+
The variable width is what makes text storage correct rather than merely
973+
convenient: `.` sorts below every digit, so lexicographic order — which is
974+
exactly what mingo performs and what a mongo text range uses — stays
975+
chronological across both widths.
976+
977+
Coverage note: the mongo end-to-end sweep needs a real server
978+
(`mongodb-memory-server`), so it runs in CI and skips where no binary is
979+
available. The conversion itself is pinned by `mongodb-time-storage.test.ts`,
980+
which is pure and runs everywhere.

packages/formula/src/matches-filter-temporal-conformance.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@
1515
*/
1616

1717
import { describe, it, expect } from 'vitest';
18-
import { TEMPORAL_CASES, TEMPORAL_ROWS } from '@objectstack/spec/data';
18+
import {
19+
TEMPORAL_CASES,
20+
TEMPORAL_ROWS,
21+
TEMPORAL_TIME_CASES,
22+
TEMPORAL_TIME_ROWS,
23+
} from '@objectstack/spec/data';
1924

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

@@ -48,4 +53,20 @@ describe('matchesFilterCondition — temporal conformance', () => {
4853
* cannot be handed. If that ever changes — if a `check` gains a token-bearing
4954
* filter form — this comment is the thing to delete, and the axis is already
5055
* sitting in the shared table ready to be consumed.
56+
*
57+
* The same reasoning covers the wall-clock sweep below: `TemporalTimeCase`
58+
* carries no token spelling at all, because no relative-date macro resolves to
59+
* a time of day.
5160
*/
61+
62+
describe('matchesFilterCondition — Field.time conformance', () => {
63+
// Type-blind, so the records carry the canonical wall-clock text a converged
64+
// column presents. That the variable-width canon still orders correctly under
65+
// a plain string comparison is the assertion.
66+
for (const c of TEMPORAL_TIME_CASES) {
67+
it(c.name, () => {
68+
const got = TEMPORAL_TIME_ROWS.filter((r) => matchesFilterCondition(r, c.filter)).map((r) => r.id);
69+
expect(got, c.note).toEqual(c.expected);
70+
});
71+
}
72+
});

packages/plugins/driver-memory/src/memory-temporal-conformance.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@
1717
*/
1818

1919
import { describe, it, expect, beforeAll } from 'vitest';
20-
import { TEMPORAL_CASES, TEMPORAL_NOW, TEMPORAL_ROWS } from '@objectstack/spec/data';
20+
import {
21+
TEMPORAL_CASES,
22+
TEMPORAL_NOW,
23+
TEMPORAL_ROWS,
24+
TEMPORAL_TIME_CASES,
25+
TEMPORAL_TIME_ROWS,
26+
} from '@objectstack/spec/data';
2127
import { resolveFilterTokens } from '@objectstack/core';
2228
import { InMemoryDriver } from './memory-driver.js';
2329

@@ -63,3 +69,33 @@ describe('driver-memory — temporal conformance', () => {
6369
}
6470
}
6571
});
72+
73+
describe('driver-memory — Field.time conformance', () => {
74+
let driver: InMemoryDriver;
75+
76+
beforeAll(async () => {
77+
driver = new InMemoryDriver({});
78+
await driver.connect();
79+
await driver.syncSchema('time_conformance', {
80+
name: 'time_conformance',
81+
fields: { at: { type: 'time' }, why: { type: 'string' } },
82+
});
83+
for (const r of TEMPORAL_TIME_ROWS) {
84+
await driver.create('time_conformance', {
85+
id: r.id,
86+
// The mixed-writer axis (D-E4) for wall clocks: a `Date` write and a
87+
// canonical-text write of the SAME wall clock must converge.
88+
at: r.writerForm === 'native' ? new Date(`1970-01-01T${r.at}Z`) : r.at,
89+
why: r.why,
90+
});
91+
}
92+
});
93+
94+
for (const c of TEMPORAL_TIME_CASES) {
95+
it(c.name, async () => {
96+
const rows = await driver.find('time_conformance', { where: c.filter } as any);
97+
const got = (rows as any[]).map((r) => r.id).sort();
98+
expect(got, c.note).toEqual([...c.expected].sort());
99+
});
100+
}
101+
});

packages/plugins/driver-memory/src/memory-temporal.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
* |---|---|---|
2222
* | `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. |
2323
* | `date` | `YYYY-MM-DD` text | timezone-naive by ADR-0053 Phase 1 — an instant would re-couple it to a zone. |
24+
* | `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. |
2425
*
2526
* The rule is applied on write ({@link toStorageForms}) and to filter
2627
* comparands, which is the pairing that keeps the two sides from disagreeing.
@@ -89,8 +90,56 @@ export function storageDateValue(value: unknown): unknown {
8990
return value;
9091
}
9192

93+
/**
94+
* Collapse a `Field.time` value to a canonical timezone-naive wall clock —
95+
* `HH:MM:SS`, extended to `HH:MM:SS.fff` exactly when the milliseconds are
96+
* non-zero. Mirrors `SqlDriver`'s `canonicalTimeOfDay` (ADR-0053 D-C1), so a
97+
* fixture that moves between the two backends compares identically.
98+
*
99+
* This driver had no time rule at all, which is the same meta-problem #3994
100+
* found on SQL and #4047 found here for `datetime`: with nothing normalising
101+
* the column, a `Date` write and a `'09:00:00'` write sat side by side, and
102+
* mingo's cross-type comparison meant a text bound matched no `Date` row in
103+
* either direction. Measured: 8 of the 9 shared time cases returned only the
104+
* text-written half of the fixture.
105+
*
106+
* Why variable width rather than a fixed `.000`: `.` sorts below every digit,
107+
* so lexicographic order — which is exactly what mingo performs on strings —
108+
* stays chronological across the two widths (`'14:30:00.100' < '14:30:01'`),
109+
* and the zero-millisecond spelling stays the `HH:MM:SS` every dialect's
110+
* native TIME emits.
111+
*
112+
* A `Date` / epoch-ms / full-timestamp value folds to its **UTC** time-of-day,
113+
* never the host's, matching the platform's instant semantics everywhere else.
114+
* Total: an out-of-range wall clock (`'25:00'`) or unparseable junk passes
115+
* through untouched rather than being silently rewritten.
116+
*/
117+
export function storageTimeValue(value: unknown): unknown {
118+
if (value == null) return value;
119+
if (typeof value === 'string') {
120+
const s = value.trim();
121+
if (s === '') return value;
122+
const m = /^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?$/.exec(s);
123+
if (m) {
124+
const [, hh, mm, ss = '00', frac] = m;
125+
if (Number(hh) > 23 || Number(mm) > 59 || Number(ss) > 59) return value;
126+
const ms = frac ? `${frac}000`.slice(0, 3) : '000';
127+
return ms === '000' ? `${hh}:${mm}:${ss}` : `${hh}:${mm}:${ss}.${ms}`;
128+
}
129+
}
130+
// Not a bare wall clock — a `Date`, epoch ms, or a full/zone-naive timestamp
131+
// string. Delegate to the one function that owns instants, then keep its UTC
132+
// time-of-day. Same delegation `canonicalTimeOfDay` performs.
133+
const instant = storageDatetimeValue(value);
134+
if (typeof instant === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(instant)) {
135+
const time = instant.slice(11, 23);
136+
return time.endsWith('.000') ? time.slice(0, 8) : time;
137+
}
138+
return value;
139+
}
140+
92141
/** Which temporal rule a declared field takes, if any. */
93-
export type TemporalFieldKind = 'datetime' | 'date';
142+
export type TemporalFieldKind = 'datetime' | 'date' | 'time';
94143

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

106157
/**
@@ -114,6 +165,7 @@ export function indexTemporalFields(
114165
for (const [name, def] of Object.entries(fields ?? {})) {
115166
if (def?.type === 'datetime') out.set(name, 'datetime');
116167
else if (def?.type === 'date') out.set(name, 'date');
168+
else if (def?.type === 'time') out.set(name, 'time');
117169
}
118170
return out;
119171
}

packages/plugins/driver-mongodb/src/mongodb-filter.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@
1818

1919
import type { Filter } from 'mongodb';
2020
import { nextUtcCalendarDay } from '@objectstack/core';
21-
import { coerceTemporalValue, type TemporalFieldKindResolver } from './mongodb-temporal.js';
21+
import {
22+
coerceTemporalValue,
23+
type TemporalFieldKind,
24+
type TemporalFieldKindResolver,
25+
} from './mongodb-temporal.js';
2226

2327
/**
2428
* Translate an ObjectStack `where` clause into a MongoDB filter document.
@@ -132,7 +136,11 @@ function translateCondition(
132136
*/
133137
function translateFieldOperators(
134138
ops: Record<string, unknown>,
135-
kind?: 'datetime' | 'date',
139+
// The shared type, not a hand-copy of its members. This signature spelled
140+
// the union out literally, so widening the canon to include `time`
141+
// (ADR-0053 D-C1) left the two out of step and the call site stopped
142+
// compiling. One definition means the next temporal type is added once.
143+
kind?: TemporalFieldKind,
136144
): Record<string, unknown> {
137145
const result: Record<string, unknown> = {};
138146
const store = (v: unknown) => coerceTemporalValue(v, kind);

packages/plugins/driver-mongodb/src/mongodb-temporal-conformance.test.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,13 @@
2020

2121
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2222
import { MongoMemoryServer } from 'mongodb-memory-server';
23-
import { TEMPORAL_CASES, TEMPORAL_NOW, TEMPORAL_ROWS } from '@objectstack/spec/data';
23+
import {
24+
TEMPORAL_CASES,
25+
TEMPORAL_NOW,
26+
TEMPORAL_ROWS,
27+
TEMPORAL_TIME_CASES,
28+
TEMPORAL_TIME_ROWS,
29+
} from '@objectstack/spec/data';
2430
import { resolveFilterTokens } from '@objectstack/core';
2531
import { MongoDBDriver } from './mongodb-driver.js';
2632

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

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

6978
for (const c of TEMPORAL_CASES) {
@@ -82,3 +91,45 @@ describe.skipIf(!sharedMongod)('driver-mongodb — temporal conformance', () =>
8291
}
8392
}
8493
});
94+
95+
describe.skipIf(!sharedMongod)('driver-mongodb — Field.time conformance', () => {
96+
const mongod = sharedMongod as MongoMemoryServer;
97+
let driver: MongoDBDriver;
98+
99+
beforeAll(async () => {
100+
driver = new MongoDBDriver({ url: mongod.getUri(), database: 'time_conformance' });
101+
await driver.connect();
102+
await driver.syncSchema('time_conformance', {
103+
name: 'time_conformance',
104+
fields: { at: { type: 'time' }, why: { type: 'string' } },
105+
});
106+
for (const r of TEMPORAL_TIME_ROWS) {
107+
await driver.create('time_conformance', {
108+
id: r.id,
109+
// The mixed-writer axis for wall clocks: a `Date` write and a
110+
// canonical-text write of the SAME wall clock must converge, or BSON
111+
// type-bracket comparison hides one half from every text bound.
112+
at: r.writerForm === 'native' ? new Date(`1970-01-01T${r.at}Z`) : r.at,
113+
why: r.why,
114+
});
115+
}
116+
}, 90_000);
117+
118+
afterAll(async () => {
119+
if (driver) await driver.disconnect();
120+
});
121+
122+
for (const c of TEMPORAL_TIME_CASES) {
123+
it(c.name, async () => {
124+
const rows = await driver.find('time_conformance', { where: c.filter } as any);
125+
const got = (rows as any[]).map((r) => r.id).sort();
126+
expect(got, c.note).toEqual([...c.expected].sort());
127+
});
128+
}
129+
});
130+
131+
// The shared server outlives both suites, so it is torn down once here rather
132+
// than by whichever suite happens to finish first.
133+
afterAll(async () => {
134+
if (sharedMongod) await sharedMongod.stop();
135+
});

0 commit comments

Comments
 (0)