Skip to content

Commit 8e1c0cf

Browse files
committed
fix(objectql,core,driver-sql,driver-memory): a per-aggregation filter and having read a temporal comparand by the column's storage rule — one rule, lifted into core, shared with both drivers' where
Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
1 parent cfe2387 commit 8e1c0cf

8 files changed

Lines changed: 371 additions & 226 deletions

File tree

‎packages/core/src/index.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,13 @@ export * from './utils/analytics-date-range-conformance.js';
9797
// on each other.
9898
export * from './utils/temporal-comparand.js';
9999

100+
// [#20176] …and the storage FORM that column's rule puts a value in — the one
101+
// rule `driver-sql` and `driver-memory` apply to a `where` comparand (and on
102+
// write), and `@objectstack/objectql` applies to a per-aggregation `filter` and
103+
// `having`, which it evaluates itself. Here because the drivers and the engine
104+
// do not depend on each other, and each driver used to carry its own copy.
105+
export * from './utils/temporal-storage-form.js';
106+
100107
// [#12350 / ADR-0126 §4] THE activation-ledger row contract, parameterized by
101108
// `metadata_type`. Same reason as the two entries above: its consumers —
102109
// `@objectstack/objectql` (packaged actions) and

‎packages/core/src/utils/temporal-comparand.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@
4141
* ## Interpretability is defined by the DRIVERS' own totals, deliberately
4242
*
4343
* Each predicate below mirrors the total function that would receive the value
44-
* if the door let it through — `storageDatetimeValue` / `storageDateValue` /
45-
* `storageTimeValue` in `driver-memory`, and their `SqlDriver` twins. Those
46-
* functions are total on purpose: an input they cannot interpret is returned
44+
* if the door let it through — `temporalStorageForm` (`temporal-storage-form.ts`,
45+
* the one rule both drivers read since #20176; before it, a copy in each).
46+
* That function is total on purpose: an input it cannot interpret is returned
4747
* UNCHANGED rather than becoming an invented instant. So "the driver would
4848
* return it unchanged" IS the definition of uninterpretable, and defining it
4949
* any other way would refuse comparands that work today.
@@ -87,7 +87,7 @@ export function temporalComparandKind(fieldType: unknown): TemporalComparandKind
8787
}
8888

8989
/**
90-
* `storageDatetimeValue`'s reading, as a yes/no.
90+
* `temporalStorageForm`'s `datetime` reading, as a yes/no.
9191
*
9292
* Mirrors it step for step: a bare integer in either sign is epoch
9393
* milliseconds; a bare `YYYY-MM-DD` is midnight UTC; a zone-naive
@@ -105,11 +105,11 @@ function readsAsInstant(s: string): boolean {
105105
}
106106

107107
/**
108-
* `storageDateValue`'s reading of a STRING: its leading `YYYY-MM-DD`, and
108+
* `temporalStorageForm`'s `date` reading of a STRING: its leading `YYYY-MM-DD`, and
109109
* nothing else.
110110
*
111111
* Narrower than {@link readsAsInstant} on purpose, because the rule it mirrors
112-
* is narrower: `storageDateValue` collapses a leading calendar day and returns
112+
* is narrower: the `date` rule collapses a leading calendar day and returns
113113
* every other string untouched. `2026/07/15` is therefore uninterpretable for a
114114
* `date` column even though `Date.parse` reads it — today it survives to the
115115
* driver and compares as text against `YYYY-MM-DD` values, which is the silent
@@ -120,7 +120,7 @@ function readsAsCalendarDay(s: string): boolean {
120120
}
121121

122122
/**
123-
* `storageTimeValue`'s reading: a bare wall clock whose components are in
123+
* `temporalStorageForm`'s `time` reading: a bare wall clock whose components are in
124124
* range. Out-of-range (`25:00`) is uninterpretable — the rule it mirrors
125125
* returns such a value untouched rather than wrapping it.
126126
*/
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20176] THE storage form of a temporal value — one rule, read by every face
5+
* that compares one.
6+
*
7+
* ADR-0053 fixes one LOGICAL storage form per temporal field type:
8+
*
9+
* | Field type | Stored as | Why |
10+
* |---|---|---|
11+
* | `datetime` | canonical UTC ISO text, `YYYY-MM-DDTHH:MM:SS.sssZ` (`Date#toISOString`) | fixed width + UTC, so text order IS chronological order, and it is the wire form (D-B) |
12+
* | `date` | `YYYY-MM-DD` | a timezone-naive calendar day (Phase 1) |
13+
* | `time` | `HH:MM:SS`, `.fff` only when non-zero | a timezone-naive wall clock (D-C1); `.` sorts below every digit, so the two widths still sort chronologically |
14+
*
15+
* A filter comparand must be put into the column's form before it is compared,
16+
* or the comparison is decided by the two sides' SHAPES disagreeing rather than
17+
* by their values. Three faces apply that pairing:
18+
*
19+
* - `driver-sql` — on write (`formatInput`), on read presentation and on every
20+
* `where` comparand (`coerceFilterValue`, public as `temporalFilterValue`).
21+
* A MySQL `datetime` then takes a PHYSICAL spelling of the same instant
22+
* (`mysqlDatetimeLiteral`), which stays the driver's: it is a dialect's
23+
* literal syntax, not a second rule.
24+
* - `driver-memory` — on write and on every `where` comparand
25+
* (`coerceTemporalValue`).
26+
* - `@objectstack/objectql` — a per-aggregation `filter` and `having`, which
27+
* the engine evaluates itself over the rows a driver returned
28+
* (`having-filter.ts`, with the column's class from the object's declaration
29+
* or the aggregated row's).
30+
*
31+
* Each driver used to carry its own copy of the three functions below, word for
32+
* word; the engine carried none and compared type-blind, so an ISO instant
33+
* against a `date` column counted 1 row where the same condition in a `where`
34+
* counted 3. The copies agreed shape for shape when they were lifted here
35+
* (measured on 71 shapes × 3 kinds: `Date`s valid, invalid and out of range,
36+
* epoch numbers and strings, bare days, zone-naive and zone-explicit
37+
* timestamps, wall clocks in and out of range, junk, `null`, structures) — so
38+
* this is a move, not a change, for either driver.
39+
*
40+
* ## Form only, never bound semantics
41+
*
42+
* The function is operator-blind. The whole-day reading of a bare
43+
* `YYYY-MM-DD` used as an UPPER bound on a `datetime` column (`$lte`, a
44+
* `$between` max → `< nextUtcCalendarDay(day)`, ADR-0053 D-D) is a separate,
45+
* operator-sensitive rule every emitter applies BEFORE this one, from
46+
* `@objectstack/spec/data`'s `nextUtcCalendarDay`.
47+
*
48+
* ## Total, on purpose
49+
*
50+
* `null` / `undefined`, the empty string, an out-of-range wall clock
51+
* (`'25:00'`), an Invalid Date and unparseable junk come back UNCHANGED rather
52+
* than as an invented value — a value the rule cannot interpret is never
53+
* silently rewritten, so junk keeps failing its comparison. Which strings are
54+
* uninterpretable is the question `isUninterpretableTemporalComparand`
55+
* (`temporal-comparand.ts`) answers for the doors that refuse them.
56+
*/
57+
58+
import type { TemporalComparandKind } from './temporal-comparand.js';
59+
60+
/**
61+
* The storage form of `value` for a column of `kind` — see the module note for
62+
* the table.
63+
*
64+
* - `datetime`: a `Date` → its ISO text; a finite number, or a bare integer
65+
* string in either sign → epoch milliseconds; a bare `YYYY-MM-DD` → midnight
66+
* UTC, stated explicitly so it is never re-read in a server's local zone; a
67+
* zone-naive `YYYY-MM-DD[ T]HH:MM[:SS[.fff]]` → its wall clock read AS UTC
68+
* (the rule `CURRENT_TIMESTAMP`-written rows take on read, ADR-0074); any
69+
* other string → whatever `Date.parse` reads, re-spelled in UTC. `Z`
70+
* everywhere: `'…T12:00+08:00'` and `'…T04:00Z'` are one instant but sort
71+
* differently as text.
72+
* - `date`: a `Date` → its UTC calendar day (the UTC clock, never the host's —
73+
* `SqlDriver.toDateOnly` records why); a string → its leading `YYYY-MM-DD`.
74+
* - `time`: a bare `HH:MM[:SS[.f…]]` in range → `HH:MM:SS`, `.fff` kept only
75+
* when non-zero (fractions beyond milliseconds truncated); anything else is
76+
* read as an INSTANT by the `datetime` rule and keeps its UTC time of day.
77+
*
78+
* An array is NOT mapped — a caller comparing a list maps its members, the way
79+
* the drivers' `coerceFilterValue` / `coerceTemporalValue` do.
80+
*/
81+
export function temporalStorageForm(value: unknown, kind: TemporalComparandKind): unknown {
82+
if (kind === 'datetime') return canonicalUtcDatetime(value);
83+
if (kind === 'time') return canonicalTimeOfDay(value);
84+
return canonicalCalendarDay(value);
85+
}
86+
87+
function canonicalUtcDatetime(value: unknown): unknown {
88+
if (value == null) return value;
89+
if (value instanceof Date) {
90+
return Number.isNaN(value.getTime()) ? value : value.toISOString();
91+
}
92+
if (typeof value === 'number') {
93+
if (!Number.isFinite(value)) return value;
94+
const d = new Date(value);
95+
return Number.isNaN(d.getTime()) ? value : d.toISOString();
96+
}
97+
if (typeof value !== 'string') return value;
98+
const s = value.trim();
99+
if (s === '') return value;
100+
// A bare integer (in either JS or string form) is epoch milliseconds — the
101+
// shape better-sqlite3 wrote for every `Date` bound before the canon (#3912).
102+
if (/^-?\d+$/.test(s)) {
103+
const d = new Date(Number(s));
104+
return Number.isNaN(d.getTime()) ? value : d.toISOString();
105+
}
106+
const iso = /^\d{4}-\d{2}-\d{2}$/.test(s)
107+
? `${s}T00:00:00.000Z`
108+
: /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/.test(s)
109+
? `${s.replace(' ', 'T')}Z`
110+
: s;
111+
const ms = Date.parse(iso);
112+
return Number.isFinite(ms) ? new Date(ms).toISOString() : value;
113+
}
114+
115+
function canonicalCalendarDay(value: unknown): unknown {
116+
if (value == null) return value;
117+
if (value instanceof Date) {
118+
if (Number.isNaN(value.getTime())) return value;
119+
const y = value.getUTCFullYear();
120+
const m = String(value.getUTCMonth() + 1).padStart(2, '0');
121+
const d = String(value.getUTCDate()).padStart(2, '0');
122+
return `${y}-${m}-${d}`;
123+
}
124+
if (typeof value === 'string') {
125+
const trimmed = value.trim();
126+
if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) return trimmed.slice(0, 10);
127+
}
128+
return value;
129+
}
130+
131+
function canonicalTimeOfDay(value: unknown): unknown {
132+
if (value == null) return value;
133+
if (typeof value === 'string') {
134+
const s = value.trim();
135+
if (s === '') return value;
136+
const m = /^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?$/.exec(s);
137+
if (m) {
138+
const [, hh, mm, ss = '00', frac] = m;
139+
if (Number(hh) > 23 || Number(mm) > 59 || Number(ss) > 59) return value;
140+
const ms = frac ? `${frac}000`.slice(0, 3) : '000';
141+
return ms === '000' ? `${hh}:${mm}:${ss}` : `${hh}:${mm}:${ss}.${ms}`;
142+
}
143+
}
144+
// Not a bare wall clock — a `Date`, epoch ms, or a full / zone-naive
145+
// timestamp: an instant. Read it by the ONE instant rule, keep its UTC time.
146+
const instant = canonicalUtcDatetime(value);
147+
if (typeof instant === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(instant)) {
148+
const time = instant.slice(11, 23);
149+
return time.endsWith('.000') ? time.slice(0, 8) : time;
150+
}
151+
return value;
152+
}

‎packages/drivers/driver-memory/src/memory-temporal.ts‎

Lines changed: 11 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -25,118 +25,19 @@
2525
*
2626
* The rule is applied on write ({@link toStorageForms}) and to filter
2727
* comparands, which is the pairing that keeps the two sides from disagreeing.
28-
*/
29-
30-
/**
31-
* The canonical UTC ISO text form of a `Field.datetime` value.
3228
*
33-
* Total by design: an input this cannot interpret is returned unchanged rather
34-
* than becoming `Invalid Date`, so junk keeps failing the comparison instead of
35-
* silently becoming a wrong instant. A bare `YYYY-MM-DD` means **midnight
36-
* UTC**, stated explicitly so it is never re-read in the host's local zone; the
37-
* whole-day reading of a bare day used as an UPPER bound is the separate,
38-
* operator-sensitive concern `nextUtcCalendarDay` handles (#3777/#4042) before
39-
* this runs.
40-
*
41-
* Mirrors `SqlDriver`'s `canonicalUtcDatetime` — same inputs, same outputs — so
42-
* a fixture that moves between the two backends compares identically.
43-
*/
44-
export function storageDatetimeValue(value: unknown): unknown {
45-
if (value == null) return value;
46-
if (value instanceof Date) {
47-
return Number.isNaN(value.getTime()) ? value : value.toISOString();
48-
}
49-
if (typeof value === 'number') {
50-
if (!Number.isFinite(value)) return value;
51-
const d = new Date(value);
52-
return Number.isNaN(d.getTime()) ? value : d.toISOString();
53-
}
54-
if (typeof value !== 'string') return value;
55-
const s = value.trim();
56-
if (s === '') return value;
57-
// A bare integer (in either form) is epoch milliseconds.
58-
if (/^-?\d+$/.test(s)) {
59-
const d = new Date(Number(s));
60-
return Number.isNaN(d.getTime()) ? value : d.toISOString();
61-
}
62-
const iso = /^\d{4}-\d{2}-\d{2}$/.test(s)
63-
? `${s}T00:00:00.000Z`
64-
// Zone-naive `YYYY-MM-DD[ T]HH:MM[:SS[.fff]]` → its wall clock IS UTC.
65-
: /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/.test(s)
66-
? `${s.replace(' ', 'T')}Z`
67-
: s;
68-
const ms = Date.parse(iso);
69-
return Number.isFinite(ms) ? new Date(ms).toISOString() : value;
70-
}
71-
72-
/**
73-
* Collapse a `Field.date` value to timezone-naive `YYYY-MM-DD` — a `Date` to
74-
* its UTC calendar day, a string to its leading date. Mirrors
75-
* `SqlDriver.toDateOnly`.
29+
* [#20176] The rule itself is `@objectstack/core`'s `temporalStorageForm` —
30+
* the one function `driver-sql` and the engine's per-aggregation `filter` and
31+
* `having` read too. This file carried a word-for-word copy of it
32+
* (`storageDatetimeValue` / `storageDateValue` / `storageTimeValue`, mirroring
33+
* `SqlDriver`'s `canonicalUtcDatetime` / `toDateOnly` / `canonicalTimeOfDay`);
34+
* the copies agreed shape for shape when they were lifted, so every answer
35+
* this driver gives is unchanged. What stays here is this driver's own half:
36+
* which declared fields are temporal, and that a list comparand is mapped
37+
* member by member.
7638
*/
77-
export function storageDateValue(value: unknown): unknown {
78-
if (value == null) return value;
79-
if (value instanceof Date) {
80-
if (Number.isNaN(value.getTime())) return value;
81-
const y = value.getUTCFullYear();
82-
const m = String(value.getUTCMonth() + 1).padStart(2, '0');
83-
const d = String(value.getUTCDate()).padStart(2, '0');
84-
return `${y}-${m}-${d}`;
85-
}
86-
if (typeof value === 'string') {
87-
const trimmed = value.trim();
88-
if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) return trimmed.slice(0, 10);
89-
}
90-
return value;
91-
}
9239

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-
}
40+
import { temporalStorageForm } from '@objectstack/core';
14041

14142
/** Which temporal rule a declared field takes, if any. */
14243
export type TemporalFieldKind = 'datetime' | 'date' | 'time';
@@ -149,9 +50,7 @@ export type TemporalFieldKind = 'datetime' | 'date' | 'time';
14950
export function coerceTemporalValue(value: unknown, kind: TemporalFieldKind | undefined): unknown {
15051
if (kind === undefined) return value;
15152
if (Array.isArray(value)) return value.map((v) => coerceTemporalValue(v, kind));
152-
if (kind === 'datetime') return storageDatetimeValue(value);
153-
if (kind === 'time') return storageTimeValue(value);
154-
return storageDateValue(value);
53+
return temporalStorageForm(value, kind);
15554
}
15655

15756
/**

0 commit comments

Comments
 (0)