Skip to content

Temporal values, as an opt-in and as a method - #7

Merged
tamnd merged 1 commit into
mainfrom
temporal
Aug 18, 2026
Merged

Temporal values, as an opt-in and as a method#7
tamnd merged 1 commit into
mainfrom
temporal

Conversation

@tamnd

@tamnd tamnd commented Aug 18, 2026

Copy link
Copy Markdown
Owner

zu holds a date as a count of days from 1970-01-01, a time as nanoseconds from midnight, a timestamp as nanoseconds from the epoch and a duration as a count of months or nanoseconds. The four classes this client registers hand those counts over as they are, which is exact, portable and no help at all to a program that wants to know what day of the week it was.

Temporal is the standard answer to that, so a connection can ask for it:

await using conn = await connect("social.zu1", { temporal: true });
const rows = await conn.query<{ on: Temporal.PlainDate }>(`MATCH (d:day) RETURN d.on AS on`);
rows[0].on.dayOfWeek;

A DATE becomes a PlainDate, a local TIME a PlainTime, a local TIMESTAMP a PlainDateTime, a zoned TIMESTAMP a ZonedDateTime at the offset it was written at, and a DURATION a Duration counting months or seconds and nanoseconds. A time carrying an offset is the one value zu holds that Temporal has no type for, since a PlainTime is local and would drop the offset and a ZonedDateTime carries a date nobody wrote, so it stays a ZuTime and everything else in the same row still arrives converted.

The option is settled where the connection is opened and cannot be named per statement, unlike bigIntMode. Both spellings are exact and neither loses anything, so which one a program wants is a property of the program rather than of the query, and a result whose classes changed halfway through a codebase is a result nobody can write a function against. A program that wants one value converted rather than all of them calls toTemporal(), which is on all four classes and needs no option anywhere.

Going the other way needs no opt-in at all. A Temporal value passed as a parameter binds as the zu value it is on every connection, whether or not that connection asked for Temporal on the way out, because recognizing one costs a property read and refusing one would be a rule nobody could guess. They are recognized by Symbol.toStringTag, which every Temporal class sets to its own name: one property read covers all eight of them where instanceof is one call each, and it is the same answer Object.prototype.toString gives, so a value that prints as a PlainDate binds as one. A PlainYearMonth, a PlainMonthDay or a value on a calendar that is not iso8601 is refused by name, since zu holds neither, and so is a Duration counting both months and days, because no number of days is a month and a value holding both would have to invent an answer for one month after 31 January. A value outside the nanoseconds an i64 holds is refused with the range it left, 1677-09-21 to 2262-04-11.

Temporal reached Stage 4 in March 2026 and is unflagged in Node 26 and the current browsers, while Node 24 is still the active LTS and has it behind --harmony-temporal. That is the whole reason this is an opt-in rather than the default: a client that returned Temporal values everywhere would be a client half its users cannot load. Asking a runtime without it for { temporal: true } is refused on the way in and before the database file is opened, so a program that asked for what its runtime cannot do fails at the line that asked and leaves nothing behind. The check is read on the runtime's own thread and carried into the task, because only that thread may touch a JavaScript value at all.

The TypeScript types ask globalThis whether the compiler has heard of Temporal rather than importing it, since which lib declares it is different in every version of TypeScript that has shipped since Stage 4. A program compiling against a lib that has it is checked against the real types. One compiling against a lib that does not gets unknown for a return type and nothing at all for a union member, so ZuValue and ZuParam are exactly what they were before this existed and still narrow the way they did.

Nothing is cached between rows. A Temporal value is a JavaScript object and the constructors are properties of one, and neither can be held by a struct that crosses to the threadpool thread a statement runs on, so each conversion looks the constructor up through a global, a namespace and a class. On 50k rows here a DATE column costs about 560ns a row as a ZuDate and about 650ns as a Temporal.PlainDate, against 240ns for the INT64 column beside it, so the conversion is worth about a sixth of a value that was already the most expensive kind to build. The day count is turned into a date with Hinnant's civil calendar algorithms, which are branch free and have no table behind them, and they round trip over two million days in the unit tests.

Eleven tests, which adapt themselves to the runtime rather than being skipped by hand: they check the mode on every kind of value, the zoned time that keeps its class, toTemporal() without the option, every inbound class including the pre-epoch and offset cases, the refusals by name and by range, that a value goes out the way it came in, that query and stream agree, and that the mode is off unless it was asked for. Both directions run on Node 24 with npm run test:temporal, which CI now runs beside the plain suite on 24, while 26 has Temporal unflagged and covers it in the ordinary run. npm test is 90 tests green both ways, and cargo clippy --all-features, cargo fmt --check, npm run check:types and npm run check:package are green.

Part of DX3, tamnd/zu#169.

zu holds a date as a count of days, a time and a timestamp as counts of
nanoseconds and a duration as a count of months or nanoseconds, and the
four classes this client registers hand those counts over as they are.
That is exact and portable and no help at all to a program that wants
to know what day of the week it was.

A connection opened with `{ temporal: true }` gets `Temporal` values
instead, and `toTemporal()` on each of the four classes converts one
value on any connection. A time carrying an offset is the exception in
both: `Temporal` has no type for one, since a `PlainTime` is local and
a `ZonedDateTime` carries a date nobody wrote, so it stays a `ZuTime`
and everything else in the row still arrives converted.

The option is settled where the connection is opened and cannot be
named per statement, unlike `bigIntMode`. Both spellings are exact and
neither loses anything, so which one a program wants is a property of
the program rather than of the query, and a result whose classes change
halfway through a codebase is a result nobody can write a function
against.

Going the other way needs no opt-in. A `Temporal` value passed as a
parameter binds as the zu value it is on every connection, recognized
by `Symbol.toStringTag`, which is one property read for all eight of
its classes where `instanceof` would be one call each. A
`PlainYearMonth`, a `PlainMonthDay` or a value on a calendar that is
not `iso8601` is refused by name, and so is a `Duration` counting both
months and days, because no number of days is a month and a value
holding both would have to invent an answer for one month after
31 January.

`Temporal` reached Stage 4 in March 2026 and is unflagged in Node 26
and the current browsers, while Node 24 is still the active LTS and has
it behind `--harmony-temporal`, which is the whole reason this is an
opt-in rather than the default. Asking a runtime without it for
`{ temporal: true }` is refused on the way in and before the database
file is opened, so a program that asked for what its runtime cannot do
fails at the line that asked and leaves nothing behind.

The TypeScript types ask `globalThis` whether the compiler has heard of
`Temporal` rather than importing it, because which `lib` declares it is
different in every version of TypeScript that has shipped since Stage 4.
A program that has it is checked against the real types, one that does
not gets `unknown` for a return and nothing at all for a union member,
so the unions it already had are unchanged.

On 50k rows here a DATE column costs about 560ns a row as a `ZuDate`
and about 650ns as a `Temporal.PlainDate`, against 240ns for the INT64
column beside it. Nothing is cached between rows, because a `Temporal`
value is a JavaScript object and the constructors are properties of
one, and neither can be held by a struct that crosses to the threadpool
thread the statement runs on.
@tamnd
tamnd merged commit 6bec3db into main Aug 18, 2026
17 checks passed
@tamnd
tamnd deleted the temporal branch August 18, 2026 14:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant