Skip to content

Commit e5cf27d

Browse files
fix(objectql,core): a per-aggregation filter and having read a temporal comparand by the column's storage rule — one rule in core, shared with both drivers' where (#20202)
Fixes #20176 Clause-②: yes (widening) A per-aggregation `filter` (`aggregations[i].filter`) and `having` on `engine.aggregate` now read a temporal comparand by the column's storage rule — the rule both drivers already apply to the same comparand in a `where`. The rule lives in one place, `temporalStorageForm(value, kind)` in `@objectstack/core`, and `driver-sql` and `driver-memory` call it instead of each carrying a copy. Every `where` answer is byte-identical before and after, and so is every refusal PR #20174 added. ## What changed - **`@objectstack/core`** — new `utils/temporal-storage-form.ts`, exported from the index: `temporalStorageForm(value, kind)` for `kind` `'datetime' | 'date' | 'time'`. It is the body that `driver-sql`'s `canonicalUtcDatetime` / `toDateOnly` / `canonicalTimeOfDay` and `driver-memory`'s `storageDatetimeValue` / `storageDateValue` / `storageTimeValue` each carried word for word. It is total, operator-blind and does not map arrays. `temporal-comparand.ts` docs now name it. - **`driver-sql`** — the three functions' bodies call the core rule; `storageDatetimeValue` still adds MySQL's literal spelling on top (the dialect half stays in the driver). No other line moved. - **`driver-memory`** — `coerceTemporalValue` calls the core rule; the three private copies are deleted (nothing else imported them). - **`@objectstack/objectql`** - `having-filter.ts` `checkCondition` takes the column's temporal kind. When it has one, the row's value and every comparand of `$eq` / `$ne` / the four orderings / `$between` / `$in` / `$nin` / implicit equality are put in the storage form first. A bare `YYYY-MM-DD` as the upper bound of a `datetime` (`$lte`, a `$between` max) means the whole day — the exclusive bound at the next day's midnight — from the spec's `nextUtcCalendarDay` (ADR-0053 D-D), as `SqlDriver.calendarDayUpperBoundRewrite` and `driver-memory`'s `$lte` arm do. Presence tests, text operators and `{ $field }` references are read as before. `matchesHaving`, `applyHaving` and `matchesAggregationFilter` thread a class map down; `declaredFieldClasses(fields)` is new (the object's declared fields, classified by the same spec sets #20127's `aggregatedRowColumnClasses` uses). - `in-memory-aggregation.ts` — `applyInMemoryAggregation(rows, ast, timezone?, fields?)`: the optional declared field map reaches each per-aggregation filter. Without it the function answers as before. - `engine.ts` `ObjectQL.aggregate` — reads the declaration once, keeps `having`'s aggregated column classes (already computed for #20127's `addDays` rule) and hands them to both `applyHaving` doors, and hands the field map to `applyInMemoryAggregation`. ## The rows, measured Real `InMemoryDriver` and real `SqlDriver` (better-sqlite3, schema-synced), through `engine.aggregate` and through `POST /api/v1/data/:object/query` (JSON round-tripped body), on a populated (6 rows, 3 groups) and an empty object; the per-aggregation filter on the second of two counts, the `where` twin beside it. Base `cfe2387a3b` vs head (code identical to `b48f417154`). Every empty-object cell is 0 / `[]` at base and head. | # | shape | base, populated (all 4 driver × door cells) | head | `where` twin | |:--|:--|:--|:--|:--| | 1 | ISO instant `$gte` on a `date` | 1 | 3 | 3 | | 2 | ISO instant `$eq` on a `date` | 0 | 2 | 2 | | 3 | bare day `$lte` on a `datetime` | 2 | 3 | 3 | | 4 | bare day as `$between` max on a `datetime` | 2 | 3 | 3 | | 5 | epoch-ms `$gt` on a `datetime` | 0 | 3 | 3 | | 6 | `Date` 10:00 on a `date`, `$gte` / `$lt` / `$eq` | 1 / 5 / 0 | 3 / 3 / 2 | 3 / 3 / 2 | | 7 | `Date` `$gt` on a `time` | 0 | 3 | 3 | | 8 | `having` ISO instant `$gte` on `max(date)`, both doors | c2 | c2, c3 | the date rule: c2, c3 | | control | zone-naive `'2026-02-01 09:00'` `$gt` on a `datetime` | 4 | 4 | 4 | Rows 6 and 7 are in-process only on the engine door; over REST the `Date` arrives as its ISO text, and that spelling moved the same way. Rows 6/7 on the base were identical to the card's readings, so PR #20174's instant lift did not move them. The family H1 asked for, all now equal to their `where` twin on every cell (base in brackets): `$ne` 4 (6), implicit equality 2 (0), `$in` members 3 (0), `$nin` members 3 (6), `$between` endpoints 4 (3), ISO `$lt` 3 (5) on a `date`; bare days as both `$between` endpoints 2 (1), epoch-ms string 3 (6), epoch-ms `$lte` 3 (0) and `$eq` 1 (0), offset instant `$gte` 4 (3) and `$eq` 1 (0), zone-naive `T` `$eq` 1 (0), ISO without ms `$eq` 1 (0), `$ne` 5 (6), `$nin` 5 (6) on a `datetime`; ISO instant `$gt` 3 (0) and `$eq` 1 (0), short wall clock `$eq` 1 (0), `$lte` 3 (2), `$in` 2 (0), `$between` 3 (2), `.000` spelling `$eq` 1 (0), bare day `$lte` 0 (6), `Date` implicit 1 (0) on a `time`; `Date` `$in` 2 (0), `$nin` 4 (6), `$between` 4 (3) on a `date`; the ISO row under `$or` 3 (1), `$not` 3 (5), `$and` 2 (0); two operators on one `datetime` 2 (1) and a bare-day `$lte` beside an author `$lt` 4 (3); a bare-day `$lte` of today on `created_at` 6 (0). `having`, both doors: 15 more shapes on `max(date)`, `min(date)`, `min(datetime)`, `max(time)`, a `groupBy` of a `date` / `datetime` field and a `day` bucket, each moved to the date/datetime/time rule's answer. ## H2 — do the two drivers' rules agree? Measured before lifting: `driver-memory`'s `coerceTemporalValue` against `SqlDriver.temporalFilterValue` (better-sqlite3, pg and mysql2 clients, field maps seeded, no connection) on 71 shapes × 3 kinds (`Date`s valid, invalid and out of range, numbers incl. NaN / Infinity / past the Date range, epoch strings, bare days incl. impossible ones, zone-naive and zone-explicit timestamps, RFC 2822, wall clocks in and out of range, junk, placeholders, `null`, booleans, objects, nested and mixed arrays, bigint): memory = sqlite = pg on all 213 cells; mysql differs on the 36 `datetime` cells only by its literal spelling. Base to head, all 852 cells are byte-identical, so the re-pointing moved nothing. One divergence sits in the operator-sensitive layer, not in the lifted function: a bare day as a `$lte` / `$between` max on a **`time`** field. `driver-memory` widens it (`$lt '00:00:00'`, no row), `driver-sql` does not (`$lte '00:00:00'`, keeps a midnight row). Neither driver's emitter is touched here. This position follows the spec: ADR-0053 D-D's table defines the whole-day reading for `datetime`, and `TEMPORAL_TIME_CASES` says it must not reach a time column. Reported, not filed here. ## Tests - New: `packages/objectql/src/engine-aggregate-temporal-storage-rule.test.ts` (84 — the card's rows, the family, unmoved positions, `having` on both doors, the shared `TEMPORAL_CASES` + token spellings + `TEMPORAL_TIME_CASES` through a per-aggregation filter and through `having`, and the no-declaration default); `packages/rest/src/aggregation-filter-temporal-storage-rule.test.ts` (13, REST + SqlDriver, per-aggregation count equals the `where` twin, empty and populated, and `having` row 8); `packages/drivers/driver-memory/src/memory-temporal-storage-form.test.ts` (59 — `coerceTemporalValue` is the core rule, and the card's `where` twins on this driver); `packages/drivers/driver-sql/src/sql-driver-temporal-storage-form.test.ts` (10 — `temporalFilterValue` is the core rule on three clients); `packages/core/src/utils/temporal-storage-form.test.ts` (52). - Whole suites at `b48f417154` (after merging `main`, full package build): objectql `317 files / 5712 passed`; core `54 / 1410`; driver-memory `54 / 1328`; driver-sql `188 passed, 11 skipped / 3099 passed, 170 skipped`; REST aggregate files (5 incl. the new one) `109 passed, 1 skipped`; service-analytics `128 / 3017`. - Live PostgreSQL 16 (private server, server zone Asia/Shanghai, process `TZ=America/New_York`): driver-sql whole suite `196 files passed, 3 skipped / 3806 tests passed, 86 skipped`; `sql-driver-temporal-conformance` 103 with only its MySQL cell skipped. MySQL: NOT MEASURED, no server in this container. - Skewed process zone (America/New_York, Asia/Kolkata, Pacific/Chatham): the new objectql + `engine-aggregate-filter`, driver-memory new + temporal conformance, core new — all green in each zone. - Typecheck `core`, `driver-memory`, `driver-sql`, `objectql`, `rest`: exit 0 each; every new test file is in a typecheck program (`--listFiles`); objectql test-typecheck debt unchanged at 40 files / 234 errors. - Lint (declared narrowing): `eslint --no-inline-config --format json` on the 13 changed `.ts` files → 13 files, 0 errors / 0 warnings / 0 fatal; `--print-config` resolves a config for each; `eslint.config.mjs` enables no type-aware linting, so no untouched file's verdict can move. ## Reverse verification Fix committed first. `ablation-replace` (wrap mode, `EXIT/INT/TERM` trap on the absolute path) replaced the one threading site in `matchesHaving` so no column ever gets its kind: anchor 1 to 0, blob `77400359cc` to `7a2f5b7f14`. objectql rebuilt; `ablation-dist-preflight --absent` passed on all 14 built files. New objectql pins `58 failed / 26 passed (84)` — the 26 are the controls, the unmoved positions, the #20148 `Date` lift on an undeclared column and the kit cases whose canonical comparands compare correctly as text. REST pins `11 failed / 2 passed (13)` — the two controls. The harness in that state reproduced the base table 5024 / 5024. Restored by `git checkout HEAD --`: blob back to `77400359cc` = HEAD, `git diff HEAD` empty; rebuilt; preflight marker present in 4 built files and the tree clean; pins `84` and `13` passed. ## Gates `dispatch-gates --commands --repo objectstack-ai/objectstack` at `b48f417154`: 69 commands (the clue list's 56 plus `check-adr-0087-registration`, `check-empty-changeset`, `release-rehearsal-clone --self-test`, `check:engine-double-contract`, `check:objectql-double-limit`, `check:objectui-changeset`, `check:pm-changeset-deadline-census`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt`, `check:where-matcher`). 68 exit 0; `--ran` reconciles 69 of 69, 0 NOT MEASURED. One is red on purpose: - `check-empty-changeset --base origin/main` exits 1 on `.changeset/20148-aggregation-filter-where-doors.md` — a **deliberate correction**, not a collision, and it needs a person's confirmation here. That pending note said "Two readings still differ from a `where`" (a `Date` with a time of day on a `date` field, and a `Date` on a `time` field). This PR closes both, so the sentence would ship false in the same release. The one-clause correction reads "Two readings still differed from a `where` in this change alone … (#20176 closes both in the same release, reading every temporal comparand at this position by the column's storage rule)". Nothing else in the note changed. Also run: the three roster gates whose roster sits under `packages/` (`check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`) and `check:published-readme-exports`, exit 0 each; `check-issue-citations --base 560b724` (the merge base): 28 citations, all resolve; a control-byte scan of the changed files found none. ## Deviations - The claim scopes `engine.ts` to "the per-aggregation loop only". The loop runs before filter tokens resolve, so a comparand normalised there would miss every `{today}`-style bound, which resolves to a bare day later. The normalisation therefore happens where the filter is evaluated (`checkCondition`), and the `engine.ts` edit is the threading: one declaration read beside the existing `having` class read, and the class maps passed to both `applyHaving` calls and to `applyInMemoryAggregation`. `in-memory-aggregation.ts` carries the per-aggregation filter's class map, not `having`'s (which the engine hands to `applyHaving` directly). - The re-pointing in `driver-sql` replaces the bodies of `canonicalUtcDatetime`, `toDateOnly` and `canonicalTimeOfDay` (which `temporalFilterValue` reaches through `coerceFilterValue`) rather than only `temporalFilterValue`, so the driver's write and read paths use the same single rule. The H2 measurement is the byte-identity evidence. - The REST `where` twin on `InMemoryDriver` is measured (scratch harness, 5120 cells) but not committed as a pin: `packages/rest` has no `driver-memory` dependency. That driver's `where` half is pinned in its own package. - `.changeset/20148-aggregation-filter-where-doors.md`: one-clause deliberate correction, above. ## Acceptance notes - `driver-mongodb`'s `mongodb-temporal.ts` keeps its own copy of the rule, in BSON `Date` form for `datetime`. Its instants agree with the core rule, but it is outside this claim and was not re-pointed. - `core`'s `temporal-comparand.ts` predicates (`readsAsInstant`, `readsAsCalendarDay`, `readsAsWallClock`) restate the rule's regexes as yes/no questions for the temporal-comparand door. Deriving them from `temporalStorageForm` would remove the last mirror in `core`; not done here. - `ObjectQL.resolveNowDefault` spells the `date` / `time` forms of a `Date` by hand; it agrees with the rule for every `Date` it is handed. - A per-aggregation filter and `having` on a column whose class the declaration cannot tell (no field map, a formula) keep the previous comparison, including PR #20174's instant reading of a `Date` bound. - An epoch-ms number against a `date` field is not read by the `date` rule on either driver, and the two drivers' `where` answer differently (driver-memory 0 of 6, driver-sql 6 of 6). This position answers 0, before and after; reported separately. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9dacf61 commit e5cf27d

15 files changed

Lines changed: 1179 additions & 227 deletions

‎.changeset/20148-aggregation-filter-where-doors.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,6 @@ These refusals run after the walker's own (an unknown operator, a malformed refe
2424

2525
Not refused, but answering differently:
2626

27-
- **A `Date` bound is compared as an instant.** `{ opened_at: { $gt: new Date('2026-02-01') } }` against the ISO text a `datetime` column holds counted no row: JS compared the `Date` with the string by coercion. `$ne` and `$nin` counted every row, and a `$between` of two `Date`s counted every row. The same bound in a `where` counted 4 of 6 on both drivers. The comparison now reads the pair through `@objectstack/spec/data`'s `utcInstantMs`, the lift `@objectstack/formula`'s evaluator applies, whenever one side is a `Date` and both sides denote an instant. Every `datetime` row and a UTC-midnight `Date` on a `date` field now count what the same bound counts in a `where`. `having` shares this comparison, so a `Date` bound in `having` keeps the groups its ISO spelling keeps on a `datetime` column; on a `date`-class column (`min` / `max` of a `date` field) a UTC-midnight `Date` follows the calendar-day reading a `where` gives (`{ $gte: new Date('2026-02-01') }` keeps the group whose max is `2026-02-01`, and so does `$eq`), which the ISO-instant text, compared as text, did not. A `Date` is not JSON, so this reaches in-process callers only. Two readings still differ from a `where`, because the per-row comparison holds no declaration: a `Date` carrying a time of day against a `date` field, which a `where` reads as that UTC calendar day, and a `Date` against a `time` field, which is not an instant and is left as before.
27+
- **A `Date` bound is compared as an instant.** `{ opened_at: { $gt: new Date('2026-02-01') } }` against the ISO text a `datetime` column holds counted no row: JS compared the `Date` with the string by coercion. `$ne` and `$nin` counted every row, and a `$between` of two `Date`s counted every row. The same bound in a `where` counted 4 of 6 on both drivers. The comparison now reads the pair through `@objectstack/spec/data`'s `utcInstantMs`, the lift `@objectstack/formula`'s evaluator applies, whenever one side is a `Date` and both sides denote an instant. Every `datetime` row and a UTC-midnight `Date` on a `date` field now count what the same bound counts in a `where`. `having` shares this comparison, so a `Date` bound in `having` keeps the groups its ISO spelling keeps on a `datetime` column; on a `date`-class column (`min` / `max` of a `date` field) a UTC-midnight `Date` follows the calendar-day reading a `where` gives (`{ $gte: new Date('2026-02-01') }` keeps the group whose max is `2026-02-01`, and so does `$eq`), which the ISO-instant text, compared as text, did not. A `Date` is not JSON, so this reaches in-process callers only. Two readings still differed from a `where` in this change alone, because the per-row comparison held no declaration (#20176 closes both in the same release, reading every temporal comparand at this position by the column's storage rule): a `Date` carrying a time of day against a `date` field, which a `where` reads as that UTC calendar day, and a `Date` against a `time` field, which is not an instant and is left as before.
2828

2929
Not changed, measured identical before and after on both drivers: every `where`, `groupBy` and `having` answer outside the `Date` shapes above, and a per-aggregation filter that uses implicit equality, ordering bounds, `$in`, `$between`, `$or`, `{}`, a `{ $field }` between two declared numeric fields, `addDays` between two `date` or two `datetime` fields (literal or read from a numeric column), and a reference to `created_at` or `id`.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/core": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/driver-sql": patch
5+
"@objectstack/driver-memory": patch
6+
---
7+
8+
fix(objectql,core): a per-aggregation `filter` and `having` on `engine.aggregate` read a temporal comparand by the column's storage rule, the rule `where` already applies — one function, `temporalStorageForm`, now exported by `@objectstack/core` and shared by both drivers (#20176)
9+
10+
A per-aggregation `filter` (`aggregations[i].filter`) and `having` are evaluated by the engine itself, over the rows (or aggregated rows) a driver returns. Both compared a temporal comparand exactly as written, while the same condition as a `where` is put into the column's storage form by the driver first. So they counted differently. Measured through `engine.aggregate` and through `POST /data/:object/query`, on `driver-memory` and `driver-sql`, over six rows:
11+
12+
| in `aggregations[i].filter` (or `having`) | before | now, and the `where` twin |
13+
|:--|:--|:--|
14+
| an ISO instant on a `date` field, `{ placed_on: { $gte: '2026-02-01T00:00:00.000Z' } }` | 1 | 3 |
15+
| the same instant under `$eq` | 0 | 2 |
16+
| a bare day as the upper bound of a `datetime`, `{ opened_at: { $lte: '2026-02-01' } }`, or as a `$between` max | 2 | 3 |
17+
| an epoch-millisecond bound on a `datetime` | 0 | 3 |
18+
| a `Date` carrying a time of day on a `date` field, `$gte` / `$lt` / `$eq` (in-process only) | 1 / 5 / 0 | 3 / 3 / 2 |
19+
| a `Date` on a `time` field (in-process only) | 0 | 3 |
20+
| `having` on `max` of a `date` field with an ISO-instant bound | kept one group | keeps the two groups whose day is on or after it |
21+
22+
The same holds for `$ne`, `$in` / `$nin` members, `$between` endpoints, implicit equality, an offset instant (`'…T18:00:00+08:00'`), an epoch-millisecond string, a zone-naive `'2026-02-01T10:00'`, and a short wall clock (`'11:00'`) or an ISO instant on a `time` field. On a `having` column, the class comes from the query, as the `addDays` rule already reads it: `min` / `max` take the class of the field they read, a `groupBy` projection takes its field's, and a `day` date bucket is a `date`.
23+
24+
What the rule does, now in one place:
25+
26+
- A comparand, and the row's value, are put into the column's storage form: canonical UTC ISO text for `datetime`, `YYYY-MM-DD` for `date`, and `HH:MM:SS` (`.fff` only when non-zero) for `time`.
27+
- A bare `YYYY-MM-DD` used as the upper bound of a `datetime` (`$lte`, a `$between` max) means that whole day, as it does in a `where` (ADR-0053 D-D). On a `date` or `time` column it is not widened.
28+
- A value the rule cannot read is compared as written, and so is every non-temporal column, presence tests (`$exists`, `$null`), the text operators and a `{ $field }` reference.
29+
- An object whose declared fields the engine cannot see keeps the previous comparison.
30+
31+
`@objectstack/core` exports the rule as `temporalStorageForm(value, kind)`, `kind` being `'datetime' | 'date' | 'time'`. `driver-sql` (`canonicalUtcDatetime`, `toDateOnly`, `canonicalTimeOfDay`) and `driver-memory` (`coerceTemporalValue`) each carried a copy of it; both now call it. The copies agreed on every shape measured when they were lifted, so no `where`, write or read answer of either driver changes. MySQL still binds a `datetime` in its own literal spelling.
32+
33+
`@objectstack/objectql`'s `applyInMemoryAggregation(rows, ast, timezone?, fields?)` takes the object's declared field map as an optional fourth argument, and a per-aggregation `filter` reads a temporal comparand by the rule only when it is given. Called without it, the function answers as before.
34+
35+
Not changed, measured identical before and after on both drivers: every `where` answer, every refusal a per-aggregation `filter` or `having` gives, and every per-aggregation `filter` and `having` cell whose column is not temporal.

‎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: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#20176] `temporalStorageForm` — the ONE storage rule a temporal comparand is
4+
// put in before it is compared: by `driver-sql` and `driver-memory` on `where`
5+
// (and on write), and by `@objectstack/objectql` on a per-aggregation `filter`
6+
// and `having`. Each driver carried a word-for-word copy until it was lifted
7+
// here; the drivers' own suites pin that their `where` coercion IS this
8+
// function (`memory-temporal-storage-form.test.ts`,
9+
// `sql-driver-temporal-storage-form.test.ts`). This file pins the rule itself.
10+
11+
import { describe, it, expect } from 'vitest';
12+
import { temporalStorageForm } from './temporal-storage-form.js';
13+
14+
describe('temporalStorageForm — datetime: canonical UTC ISO text', () => {
15+
const cases: ReadonlyArray<readonly [string, unknown, unknown]> = [
16+
['a Date', new Date('2026-02-01T10:00:00.000Z'), '2026-02-01T10:00:00.000Z'],
17+
['epoch milliseconds', 1769940000000, '2026-02-01T10:00:00.000Z'],
18+
['an epoch-millisecond string, padded', ' 1769940000000 ', '2026-02-01T10:00:00.000Z'],
19+
['a negative epoch string', '-86400000', '1969-12-31T00:00:00.000Z'],
20+
['a bare day — midnight UTC, never the host zone', '2026-02-01', '2026-02-01T00:00:00.000Z'],
21+
['a zone-naive wall clock — read AS UTC', '2026-02-01 09:00', '2026-02-01T09:00:00.000Z'],
22+
['a zone-naive T spelling with a long fraction', '2026-02-01T09:00:00.123456', '2026-02-01T09:00:00.123Z'],
23+
['an ISO instant without milliseconds', '2026-02-01T10:00:00Z', '2026-02-01T10:00:00.000Z'],
24+
['an offset instant — re-spelled in Z', '2026-02-01T18:00:00+08:00', '2026-02-01T10:00:00.000Z'],
25+
];
26+
for (const [name, input, expected] of cases) {
27+
it(name, () => expect(temporalStorageForm(input, 'datetime')).toBe(expected));
28+
}
29+
});
30+
31+
describe('temporalStorageForm — date: the UTC calendar day', () => {
32+
const cases: ReadonlyArray<readonly [string, unknown, unknown]> = [
33+
['an ISO instant — its leading day', '2026-02-01T00:00:00.000Z', '2026-02-01'],
34+
['a Date carrying a time of day — its UTC day', new Date('2026-02-01T23:30:00.000Z'), '2026-02-01'],
35+
['a bare day, padded', ' 2026-02-01 ', '2026-02-01'],
36+
['a zone-naive timestamp', '2026-02-01 23:59', '2026-02-01'],
37+
];
38+
for (const [name, input, expected] of cases) {
39+
it(name, () => expect(temporalStorageForm(input, 'date')).toBe(expected));
40+
}
41+
42+
it('epoch milliseconds are not a calendar day — returned as they came', () => {
43+
expect(temporalStorageForm(1769940000000, 'date')).toBe(1769940000000);
44+
});
45+
});
46+
47+
describe('temporalStorageForm — time: the UTC wall clock, .fff only when non-zero', () => {
48+
const cases: ReadonlyArray<readonly [string, unknown, unknown]> = [
49+
['a short wall clock', '09:00', '09:00:00'],
50+
['a zero-millisecond suffix is trimmed', '09:00:00.000', '09:00:00'],
51+
['a fraction is kept to milliseconds', '09:00:00.5', '09:00:00.500'],
52+
['a Date — its UTC time of day', new Date('2026-02-01T11:00:00.000Z'), '11:00:00'],
53+
['an ISO instant — its UTC time of day', '2026-02-01T11:00:00.250Z', '11:00:00.250'],
54+
['a bare day — its midnight', '2026-02-01', '00:00:00'],
55+
];
56+
for (const [name, input, expected] of cases) {
57+
it(name, () => expect(temporalStorageForm(input, 'time')).toBe(expected));
58+
}
59+
});
60+
61+
describe('temporalStorageForm — total: what the rule cannot read comes back unchanged', () => {
62+
const invalid = new Date('nope');
63+
const untouched: ReadonlyArray<readonly [string, unknown]> = [
64+
['null', null],
65+
['undefined', undefined],
66+
['the empty string', ''],
67+
['whitespace', ' '],
68+
['junk', 'not-a-date'],
69+
['a preset name', 'last_30_days'],
70+
['a placeholder', '{today}'],
71+
['a boolean', true],
72+
['an Invalid Date', invalid],
73+
['NaN', Number.NaN],
74+
];
75+
for (const kind of ['datetime', 'date', 'time'] as const) {
76+
for (const [name, input] of untouched) {
77+
it(`${kind}: ${name}`, () => expect(temporalStorageForm(input, kind)).toBe(input));
78+
}
79+
}
80+
81+
it('time: an out-of-range wall clock is not wrapped', () => {
82+
expect(temporalStorageForm('25:00', 'time')).toBe('25:00');
83+
});
84+
85+
it('a list is NOT mapped — a caller comparing a list maps its members', () => {
86+
const list = ['2026-02-01', '2026-02-02'];
87+
expect(temporalStorageForm(list, 'datetime')).toBe(list);
88+
});
89+
});

0 commit comments

Comments
 (0)