Skip to content

Commit 3000d29

Browse files
committed
docs(#14078): record the ruling in the isoFromValidDate docblocks + three patch changesets
The three copies of the sibling helper each promised "when #14078 rules, this helper collapses into the shared spelling". #14078 ruled, and the collapse is NOT mechanical: the two spellings differ across the whole non-Date domain and one of the six call sites is a required z.string().datetime() field for which neither ruled terminal value validates. Filed as #16422; every docblock and neutrality pin that called #14078 "open" now says what was ruled and what was not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
1 parent 7dc7f21 commit 3000d29

9 files changed

Lines changed: 118 additions & 54 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
Serve an Invalid `Date` from a driver instead of raising `RangeError` at two metadata read seams.
6+
7+
`canonicalIsoInstant` (`sys-metadata-repository.ts`) and the `occurredAt` arm inside `auditMetaItem` (`protocol.ts`) both reached `value.toISOString()` for any `Date`. That call raises `RangeError: Invalid time value` for the one `Date` whose time value is `NaN`, so a single bad row answered **500** on a read path — where the spelling these repairs replaced, `String(value)`, had served a visibly-wrong field the caller could see and report.
8+
9+
The shape is measured, not hypothetical: mysql2 3.23.1 returns a module constant literally named `INVALID_DATE` for a zero `DATETIME`, and postgres-date 1.0.7 builds `new Date(NaN)` for every year in 275760..294276 — a range Postgres itself stores. Legacy imports, hand migrations and a MySQL database shared with another application are all ordinary ways such a row arrives.
10+
11+
Both arms now guard on `Number.isNaN(value.getTime())`, and the terminal value is chosen per call site rather than uniformly:
12+
13+
- `canonicalIsoInstant` answers `undefined`, so each caller's existing `?? <default>` chain — the branch an absent column already takes — keeps its meaning. Its consumers are machines, and one forwards into a `z.string().datetime()` field that visible text would fail.
14+
- `auditMetaItem`'s `occurredAt` falls into the `String(...)` arm already beside it, which renders exactly `"Invalid Date"`. `AuditMetaItemResponseSchema.events[].occurredAt` is a required plain `z.string()` read by an operator in Studio's audit tab, so the text satisfies the contract and one bad row no longer blanks the page.
15+
16+
Neither answer is a blank: a silent empty value is the shape that hides the producer's bug.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/metadata': patch
3+
---
4+
5+
Serve an Invalid `Date` from a driver instead of raising `RangeError` in `DatabaseLoader.stat`.
6+
7+
`canonicalIsoInstant` reached `value.toISOString()` for any `Date`, and that call raises `RangeError: Invalid time value` for the one `Date` whose time value is `NaN`. `stat()` is a hot read path — REST `/meta/*`, ObjectQL plan resolution, runtime overlay merges — so one legacy `sys_metadata` row answered **500** where the spelling this repair replaced had served a visibly-wrong value.
8+
9+
The shape is measured: mysql2 3.23.1 hands back a constant literally named `INVALID_DATE` for a zero `DATETIME`, and postgres-date 1.0.7 builds `new Date(NaN)` for every year in 275760..294276, which Postgres itself stores.
10+
11+
The `Date` arm now guards on `Number.isNaN(value.getTime())` and answers `undefined`, so `stat()`'s own `?? new Date().toISOString()` — the branch an absent column already takes — publishes a parseable `MetadataStats.mtime`. `undefined` rather than visible text is deliberate here: `mtime` is declared `z.string().datetime()`, so the text `"Invalid Date"` would not produce a readable cell, it would produce a zod refusal at the consumer, moving the failure instead of removing it. A blank is excluded for the opposite reason — it hides the producer's bug.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
Serve an Invalid `Date` from a driver as visible text instead of raising `RangeError` in the import-job DTO and the CSV export.
6+
7+
`canonicalIsoStamp` and `formatCsvCell` both reached `value.toISOString()` for any `Date`, and that call raises `RangeError: Invalid time value` for the one `Date` whose time value is `NaN` — so one bad timestamp column answered **500** on `GET /api/v1/data/import/jobs/:jobId` and aborted a CSV export mid-stream.
8+
9+
The shape is measured: mysql2 3.23.1 returns a module constant literally named `INVALID_DATE` for a zero `DATETIME`, and postgres-date 1.0.7 builds `new Date(NaN)` for every year in 275760..294276, a range Postgres itself stores.
10+
11+
Both arms now guard on `Number.isNaN(value.getTime())` and render the visible text `"Invalid Date"` — the rendering the spelling they replaced produced. Both are read by a human, and the declared contracts allow it: the four import-job stamps are plain `z.string()` (not `z.string().datetime()`), and a CSV cell has no schema at all. The operator sees a wrong-looking field they can report, rather than an error naming no row.
12+
13+
The CSV arm needs its own guard rather than a fall-through, because the branch below it is `JSON.stringify` and `Date.prototype.toJSON` answers `null` for an Invalid `Date` — the silent blank this change exists to avoid. Both CSV paths land on the guarded arm: with field metadata, `formatDate` rejects an Invalid `Date` and passes the value through unchanged.

packages/metadata-protocol/src/protocol-14038-list-commits-created-at-iso.test.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,15 @@
3535
* did NOT adopt `canonicalIsoInstant` (`sys-metadata-repository.ts` /
3636
* `database-loader.ts`), because #14078 measured an Invalid `Date` reachable
3737
* on BOTH live dialects (a MySQL zero datetime; any Postgres year in
38-
* 275760..294276) where that spelling's `value.toISOString()` raises
39-
* `RangeError`, and #13973 is `pm:blocked` on that ruling. This card follows
40-
* #14037's precedent: `isoFromValidDate` in `protocol.ts` converts the ONE
41-
* measured shape (a valid `Date`) and returns every other shape — including
42-
* an Invalid `Date` — UNCHANGED. §D below is the pin that this card does not
43-
* decide #14078: it goes red the moment anyone swaps the contested spelling
44-
* into this site.
38+
* 275760..294276) where that spelling's `value.toISOString()` raised
39+
* `RangeError`. #14078 has since ruled (option B, 2026-09-02) and that arm is
40+
* now total, answering `undefined` for the shape. This card's route is
41+
* unchanged: `isoFromValidDate` in `protocol.ts` converts the ONE measured
42+
* shape (a valid `Date`) and returns every other shape — including an Invalid
43+
* `Date` — UNCHANGED, which is what `listCommits` promises its callers. §D
44+
* below stays the pin on that promise: it goes red the moment anyone swaps
45+
* the other spelling into this site, now the separately-tracked consolidation
46+
* decision #16422.
4547
*
4648
* ## Reverse verification, direction predicted BEFORE running
4749
*
@@ -160,12 +162,13 @@ describe('[#14038] listCommits emits the ISO-8601 string createdAt is declared a
160162
* Postgres year in 275760..294276), and whether the shared
161163
* canonical-ISO spelling (`canonicalIsoInstant`) should throw on it
162164
* (option A) or fall back to a rendering (option B) is a maintainer
163-
* call across four packages. Until it is ruled, this site hands that
164-
* one shape through exactly as it does today — no new throw, no
165+
* call across four packages; it was ruled B on 2026-09-02 for the
166+
* five arms that THREW, and this site was not one of them. It hands
167+
* that one shape through exactly as it does today — no new throw, no
165168
* invented rendering. This case is what makes that a PIN rather than
166169
* a claim: it goes red the moment `canonicalIsoInstant` (or any
167170
* spelling that reaches `.toISOString()` unconditionally) is swapped
168-
* into `listCommits`.
171+
* into `listCommits`. The consolidation is #16422.
169172
*/
170173
it('hands the value through unchanged instead of raising RangeError', async () => {
171174
const invalid = new Date(NaN);

packages/metadata-protocol/src/protocol.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1698,16 +1698,17 @@ function compareAuditInstants(a: unknown, b: unknown): number {
16981698
*
16991699
* ⚠️ Deliberately NOT the `canonicalIsoInstant` spelling next door in
17001700
* `sys-metadata-repository.ts` / `database-loader.ts` (#14037's sibling
1701-
* sites): that spelling reaches `value.toISOString()` for ANY `Date`, which
1702-
* raises `RangeError: Invalid time value` on an Invalid `Date` — measured
1703-
* reachable on BOTH live dialects (a MySQL zero datetime; any Postgres year
1704-
* in 275760..294276) and the open subject of #14078, which #13973 is
1705-
* blocked on. Whether the shared spelling should throw there (option A) or
1706-
* fall back to a rendering (option B) is a maintainer call across four
1707-
* packages, so this repair imports NEITHER answer into a new call site: an
1708-
* Invalid `Date` is returned unchanged, exactly as the raw assignment
1709-
* passed it through today. When #14078 rules, this helper collapses into
1710-
* the shared spelling.
1701+
* sites). That difference used to be exactly one input shape — the Invalid
1702+
* `Date` on which that spelling raised `RangeError: Invalid time value`,
1703+
* measured reachable on BOTH live dialects (a MySQL zero datetime; any
1704+
* Postgres year in 275760..294276). #14078 has since RULED it (option B,
1705+
* 2026-09-02): that arm is now total and answers `undefined` for the shape.
1706+
*
1707+
* ⛔ They are still not ONE spelling, and this copy has the strongest reason
1708+
* of the three not to be collapsed — see the paragraph below on what
1709+
* `listCommits` promises its callers for a non-`Date` value. The
1710+
* consolidation is tracked as **#16422**; #14078 ruled only the five arms
1711+
* that THREW.
17111712
*
17121713
* ⛔ NOT a tolerant fallback (#13973's standing prohibition): it teaches no
17131714
* consumer to accept an off-spec shape; it converts the one measured
@@ -1721,7 +1722,9 @@ function compareAuditInstants(a: unknown, b: unknown): number {
17211722
* `listCommits` are promised the RAW value back untouched when it is not a
17221723
* valid `Date` — an absent/opaque column must still reach `sort`'s fallback
17231724
* branch and any in-process reader exactly as before. Consolidating the
1724-
* family's near-identical copies is #14078's call, not this card's.
1725+
* family's near-identical copies was expected to be #14078's call; that
1726+
* ruling covered only the five arms that threw, so the consolidation is
1727+
* tracked separately as #16422.
17251728
*/
17261729
function isoFromValidDate(value: unknown): unknown {
17271730
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString();

packages/metadata-protocol/src/sys-metadata-repository-14037-event-ts-canonicalisation.test.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,14 @@
4444
* hand-rolled regex standing in for it.
4545
*
4646
* §C is the #14078 NEUTRALITY pin: an Invalid `Date` must reach the consumer
47-
* UNCHANGED, exactly as this cast passes it through today. The shared
48-
* `canonicalIsoInstant` spelling in this same file would instead raise
49-
* `RangeError: Invalid time value` there — measured reachable on both live
50-
* dialects — and whether it should is the open subject of #14078, which
51-
* #13973 is blocked on. This card imports neither answer, and §C goes red the
52-
* moment someone swaps the contested spelling in.
47+
* UNCHANGED, exactly as this cast passes it through today. #14078 has since
48+
* ruled (option B, 2026-09-02) and `canonicalIsoInstant` in this same file is
49+
* now TOTAL — it answers `undefined` for that shape rather than raising
50+
* `RangeError: Invalid time value`. The two helpers still differ across the
51+
* REST of the input domain, so §C keeps its job unchanged: it goes red the
52+
* moment someone swaps the other spelling into this site, which is now the
53+
* separately-tracked consolidation decision #16422 rather than an open
54+
* ruling.
5355
*/
5456

5557
import { describe, it, expect, beforeEach } from 'vitest';

packages/metadata-protocol/src/sys-metadata-repository.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -170,17 +170,24 @@ function canonicalIsoInstant(value: unknown): string | undefined {
170170
* — `MetadataManager.applyRepoEvent`, which forwards it to
171171
* `MetadataWatchEvent.timestamp` — is declared `z.string().datetime()`.
172172
*
173-
* ⚠️ Deliberately NOT {@link canonicalIsoInstant} above, and the difference is
174-
* exactly one input shape. That spelling reaches `value.toISOString()` for ANY
175-
* `Date`, which raises `RangeError: Invalid time value` on an Invalid `Date`
176-
* — measured reachable on BOTH live dialects (a MySQL zero datetime; any
177-
* Postgres year in 275760..294276) and the open subject of #14078, which
178-
* #13973 is blocked on. Whether the shared spelling should throw there
179-
* (option A) or fall back to a rendering (option B) is a maintainer call over
180-
* four packages, so this repair imports NEITHER answer into a new call site:
181-
* an Invalid `Date` is returned unchanged, exactly as this cast passes it
182-
* through today. When #14078 rules, this helper collapses into the shared
183-
* spelling.
173+
* ⚠️ Deliberately NOT {@link canonicalIsoInstant} above. That difference used
174+
* to be exactly one input shape — the Invalid `Date` on which that spelling
175+
* raised `RangeError: Invalid time value`, measured reachable on BOTH live
176+
* dialects (a MySQL zero datetime; any Postgres year in 275760..294276).
177+
* #14078 has since RULED it (option B, 2026-09-02): that arm is now total and
178+
* answers `undefined` for the shape, so the two agree on it.
179+
*
180+
* ⛔ They are still not ONE spelling, which is why #14078 did not collapse
181+
* this helper into it. `canonicalIsoInstant` returns `string | undefined` and
182+
* rewrites the whole domain (nullish -> `undefined`; anything neither `Date`
183+
* nor string -> `String(value)`), while this one returns `unknown` and hands
184+
* every non-valid-`Date` shape back UNTOUCHED. The consolidation is its own
185+
* decision — **#16422** — because it moves six call sites for `null`, for a
186+
* `number` and for an opaque column, one of which (`MetadataHistoryRecord
187+
* .recordedAt`, a REQUIRED `z.string().datetime()`) has no terminal value
188+
* either half of the #14078 ruling supplies. §C of
189+
* `sys-metadata-repository-14037-event-ts-canonicalisation.test.ts` pins the
190+
* behaviour this paragraph describes.
184191
*
185192
* ⛔ NOT a tolerant fallback (#13973's standing prohibition): it teaches no
186193
* consumer to accept an off-spec shape; it converts one measured producer

packages/metadata/src/loaders/database-loader-14037-adapter-boundary-iso.test.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,13 @@
5151
*
5252
* §D is the #14078 NEUTRALITY pin and is load-bearing for this card's scope:
5353
* an Invalid `Date` must reach the consumer UNCHANGED, exactly as these casts
54-
* pass it through today. The shared `canonicalIsoInstant` spelling would
55-
* instead raise `RangeError: Invalid time value` there — measured reachable on
56-
* both live dialects — and whether it should is the open subject of #14078,
57-
* which #13973 is blocked on. This card imports neither answer, and §D goes
58-
* red the moment someone swaps the contested spelling in.
54+
* pass it through today. #14078 has since ruled (option B, 2026-09-02) and the
55+
* shared `canonicalIsoInstant` spelling is now TOTAL — it answers `undefined`
56+
* for that shape rather than raising `RangeError: Invalid time value`. The two
57+
* helpers still differ across the REST of the input domain, so §D keeps its
58+
* job unchanged: it goes red the moment someone swaps the other spelling in,
59+
* which is now the separately-tracked consolidation decision #16422 rather
60+
* than an open ruling.
5961
*/
6062

6163
import { describe, it, expect, beforeEach } from 'vitest';

packages/metadata/src/loaders/database-loader.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -116,17 +116,24 @@ function canonicalIsoInstant(value: unknown): string | undefined {
116116
* `Date` fails outright. The cast is an assertion about a driver row, never a
117117
* measurement of one, which is why tsc reports nothing.
118118
*
119-
* ⚠️ Deliberately NOT {@link canonicalIsoInstant} above, and the difference is
120-
* exactly one input shape. That spelling reaches `value.toISOString()` for ANY
121-
* `Date`, which raises `RangeError: Invalid time value` on an Invalid `Date`
122-
* — measured reachable on BOTH live dialects (a MySQL zero datetime; any
123-
* Postgres year in 275760..294276) and the open subject of #14078, which
124-
* #13973 is blocked on. Whether the shared spelling should throw there
125-
* (option A) or fall back to a rendering (option B) is a maintainer call over
126-
* four packages, so this repair imports NEITHER answer into five new call
127-
* sites: an Invalid `Date` is returned unchanged, exactly as these casts pass
128-
* it through today. When #14078 rules, this helper collapses into the shared
129-
* spelling.
119+
* ⚠️ Deliberately NOT {@link canonicalIsoInstant} above. That difference used
120+
* to be exactly one input shape — the Invalid `Date` on which that spelling
121+
* raised `RangeError: Invalid time value`, measured reachable on BOTH live
122+
* dialects (a MySQL zero datetime; any Postgres year in 275760..294276).
123+
* #14078 has since RULED it (option B, 2026-09-02): that arm is now total and
124+
* answers `undefined` for the shape, so the two agree on it.
125+
*
126+
* ⛔ They are still not ONE spelling, which is why #14078 did not collapse
127+
* this helper into it. `canonicalIsoInstant` returns `string | undefined` and
128+
* rewrites the whole domain (nullish -> `undefined`; anything neither `Date`
129+
* nor string -> `String(value)`), while this one returns `unknown` and hands
130+
* every non-valid-`Date` shape back UNTOUCHED. The consolidation is its own
131+
* decision — **#16422** — because it moves six call sites for `null`, for a
132+
* `number` and for an opaque column, one of which (`MetadataHistoryRecord
133+
* .recordedAt`, a REQUIRED `z.string().datetime()` fed twice from here) has no
134+
* terminal value either half of the #14078 ruling supplies. §D of
135+
* `database-loader-14037-adapter-boundary-iso.test.ts` pins the behaviour this
136+
* paragraph describes.
130137
*
131138
* ⛔ NOT a tolerant fallback (#13973's standing prohibition): it teaches no
132139
* consumer to accept an off-spec shape; it converts one measured producer

0 commit comments

Comments
 (0)