Skip to content

The shared canonical-ISO normaliser turns an Invalid Date from a driver into a 500, where String() served text #14078

Description

@zhuangjianguo

Found while implementing #13994 (the import-job DTO's four timestamps). Not addressed there, and this card does not close it.

The shape

The repo now has one canonical spelling for "a driver handed me a timestamp column, the contract declares a string" — three landed copies, all with the same Date arm:

All three reach value.toISOString() for any value instanceof Date. For an Invalid Date — a Date object whose time value is NaN — that call raises RangeError: Invalid time value.

The spelling each of them replaced was String(value), which for the same input produces the string "Invalid Date".

So for that one input shape the repair trades a wrong-looking-but-served string for an uncaught exception at a serialisation seam — a 500 on a read path, rather than a visibly-wrong field the caller can see and report.

What is NOT claimed

Reachability is unmeasured. The measured input domain these normalisers were written against — enumerated in canonicalVersionInstant's docblock in protocol.ts — is Date (Postgres/MySQL/Mongo), canonical ISO text (SQLite and friends), epoch-ms number, and nullish. An Invalid Date is in none of them, which is exactly why no copy guards it and why #13994 deliberately did not invent a fourth variant to add a guard unilaterally.

The reason to look rather than close this on the enumeration alone: a driver constructs these Dates from stored bytes, and a legacy or zero-valued datetime is the classic way a driver produces new Date(NaN) rather than throwing at the driver. Whether any dialect and driver version in use actually does that here is the question — it wants a measurement against a real MySQL/Postgres with a legacy row, not a reading of the docblock.

Why it is one card and not three

The three copies are deliberately one spelling; a guard added to one and not the others re-opens the drift the single spelling closed. So the decision is about the shared spelling:

  • A — leave it. The input shape is outside the measured domain; a throw at the seam is arguably better than serving "Invalid Date" to a client, because it is loud. Cost: the loudness lands as a 500 on a read endpoint, on a row the operator cannot see from the error.
  • B — give the shared spelling a total Date arm, e.g. falling back to the previous rendering (or to '') when Number.isNaN(value.getTime()). There is in-repo precedent for exactly that guard on the same call: packages/rest/src/export-format.ts:291 and packages/rest/src/import-prepare.ts:115 both test Number.isNaN(value.getTime()) before using a Date. Cost: it re-admits a silent wrong value on a path that would otherwise fail loudly.

Either way it is a change to a spelling three packages now share, so it is a maintainer call rather than an implementer's — which is why this is filed rather than folded into #13994.

A useful first step either way is the measurement, not the fix: drive a legacy/zero datetime row through a real MySQL and a real Postgres and record what the driver materialises. If no driver produces an Invalid Date, A is settled on evidence instead of on the docblock.

Backlinks: #13994 (where this was found), #13997, #13973 (the census this family came from). None are addressed here.

Generated by Claude Code

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions