From 62161944da54d728a08377b34570a8c5f25618f2 Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:31:52 +0700 Subject: [PATCH] Temporal values, as an opt-in and as a method 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. --- .github/workflows/ci.yml | 18 +- README.md | 42 +++- bench/query.mjs | 41 +++- binding.d.cts | 138 +++++++++++- package.json | 4 +- src/conn.rs | 112 +++++++--- src/lib.rs | 1 + src/stream.rs | 6 +- src/temporal.rs | 459 +++++++++++++++++++++++++++++++++++++++ src/value.rs | 150 ++++++++++++- test/temporal.test.mjs | 300 +++++++++++++++++++++++++ test/types/cjs.cts | 15 +- test/types/esm.mts | 29 +++ types/header.d.ts | 77 ++++++- 14 files changed, 1344 insertions(+), 48 deletions(-) create mode 100644 src/temporal.rs create mode 100644 test/temporal.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e09e104..97b5e5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,11 +63,11 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] # 24 is the active LTS and the version the package requires. 26 - # is the current release, and the one where Temporal is - # unflagged, so it is where the opt-in of a later milestone will - # be exercised. Bun and Deno join this matrix with the line of - # DX3 that claims them, since claiming a runtime nothing runs on - # is how a client acquires a broken runtime. + # is the current release and the one where Temporal is + # unflagged, so the temporal tests run there without anything + # being asked for. Bun and Deno join this matrix with the line + # of DX3 that claims them, since claiming a runtime nothing runs + # on is how a client acquires a broken runtime. node: [24, 26] runs-on: ${{ matrix.os }} steps: @@ -82,3 +82,11 @@ jobs: # the engine is several minutes of LTO per row of the matrix. - run: npm run build:debug - run: npm test + # The same suite again with Temporal turned on, because on 24 it + # is behind a flag and the temporal tests skip themselves without + # it, and a test that skips everywhere is a test that proves + # nothing. Only on 24: 26 has Temporal unflagged, so the run above + # is already that run, and the flag it would be passed there is + # one V8 no longer has. + - run: npm run test:temporal + if: matrix.node == 24 diff --git a/README.md b/README.md index 082367b..610581d 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ The rows are an array, so iterating them is `for (const row of rows)` and nothin ## What works today -`connect`, `query`, `exec`, `stream`, `close`, `dispose` and `await using`. Named parameters both ways, including lists, records and nesting. Every scalar the engine has, plus nodes, edges and paths with their tables named rather than numbered, and `ZuDate`, `ZuTime`, `ZuTimestamp` and `ZuDuration`. Read-only connections, memory and thread limits. `bigIntMode`, per statement or per connection. An `AbortSignal` on any statement. The full error surface above, and `isZuError` to recognize it. Streaming, as an async iterable, as batches and as a Web Stream. Both module formats, typed separately. +`connect`, `query`, `exec`, `stream`, `close`, `dispose` and `await using`. Named parameters both ways, including lists, records and nesting. Every scalar the engine has, plus nodes, edges and paths with their tables named rather than numbered, and `ZuDate`, `ZuTime`, `ZuTimestamp` and `ZuDuration`, with `{ temporal: true }` and `toTemporal()` for the runtimes that have `Temporal`. Read-only connections, memory and thread limits. `bigIntMode`, per statement or per connection. An `AbortSignal` on any statement. The full error surface above, and `isZuError` to recognize it. Streaming, as an async iterable, as batches and as a Web Stream. Both module formats, typed separately. Build it with `npm run build`, and run the suite with `npm test`. Nothing is published yet, so `npm i zudb` is not a thing you can type at anybody's terminal, but everything it will do is built and installed on every run of the release workflow. @@ -94,6 +94,44 @@ What is traded for that is worth stating plainly, because it is the reason this The mode reaches the INT64 columns of a result and nothing else. A node's `offset`, an edge's `src`, `dst` and `ord`, and the nanosecond counts on the temporal classes stay `bigint` in both modes, because they are properties of classes the addon registers once rather than values a statement can respell. +## Dates and times, as classes or as Temporal + +A date, a time, a timestamp and a duration come back as `ZuDate`, `ZuTime`, `ZuTimestamp` and `ZuDuration`, which hold exactly what the engine holds: a count of days from 1970-01-01, a count of nanoseconds from midnight, a count of nanoseconds from the epoch, and a count of months or nanoseconds. That is exact and it is portable and it is 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: + +```ts +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; // 1, which no count of days was ever going to tell you +``` + +| zu | as a class | with `{ temporal: true }` | +|---|---|---| +| DATE | `ZuDate` | `Temporal.PlainDate` | +| TIME without an offset | `ZuTime` | `Temporal.PlainTime` | +| TIME with an offset | `ZuTime` | `ZuTime`, which is the exception below | +| TIMESTAMP without an offset | `ZuTimestamp` | `Temporal.PlainDateTime` | +| TIMESTAMP with an offset | `ZuTimestamp` | `Temporal.ZonedDateTime` | +| DURATION | `ZuDuration` | `Temporal.Duration` | + +The exception is the one value zu holds that `Temporal` has no type for. A time carrying an offset is neither a `PlainTime`, which is local and would drop the offset, nor a `ZonedDateTime`, which carries a date nobody wrote, so it stays a `ZuTime` in temporal mode rather than being converted into something it is not. Everything else in the same row still arrives converted. + +The option is settled when 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 and not 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: + +```ts +const rows = await conn.query<{ on: ZuDate }>(`MATCH (d:day) RETURN d.on AS on`); +const plain = rows[0].on.toTemporal(); // Temporal.PlainDate +``` + +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. `PlainDate`, `PlainTime`, `PlainDateTime`, `ZonedDateTime`, `Instant` and `Duration` all bind. 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. + +What it costs is the constructor and the calendar arithmetic. 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. 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. + +`Temporal` reached Stage 4 in March 2026 and is unflagged in Node 26 and the current browsers. Node 24, which is still the active LTS, has it behind `--harmony-temporal`. Asking a runtime without it for `{ temporal: true }` is refused at the connect and before the database file is opened, so a program that asked for something its runtime cannot do fails at the line that asked and leaves nothing behind. `toTemporal()` on such a runtime is refused the same way. 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. + +In TypeScript the types are named `ZuPlainDate`, `ZuPlainTime`, `ZuPlainDateTime`, `ZuZonedDateTime` and `ZuTemporalDuration`, and each is the real `Temporal` type on a program whose `lib` declares one and `unknown` on a program whose `lib` does not. Which `lib` has `Temporal` is different in every version of TypeScript that has shipped since Stage 4, and a package that hard-coded an answer would either fail to compile for half its users or promise a type their compiler has never heard of. + ## Importing it, either way ```ts @@ -126,7 +164,7 @@ Anything outside that table has no binary and no source build to fall back on, s ## Still to come -`toTemporal()` and `{ temporal: true }`, for the runtimes where Temporal is unflagged: it reached Stage 4 in March 2026 and is unflagged in Node 26, but Node 24 is still the active LTS and Safari is still behind a flag, which is why the stable types are the four classes above. Bun and Deno in CI, and the WASM build for the browser. +Bun and Deno in CI, and the WASM build for the browser. ## Runtimes diff --git a/bench/query.mjs b/bench/query.mjs index 42845ea..91bbf42 100644 --- a/bench/query.mjs +++ b/bench/query.mjs @@ -12,6 +12,10 @@ // thing. // // npm run build && npm run bench +// +// One case needs `Temporal` and is left out on a runtime without it, so +// `npm run bench:temporal` is the same run on Node 24, where `Temporal` +// is behind `--harmony-temporal`. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -31,14 +35,26 @@ const conn = await connect(join(dir, 'bench.zu1')) // The declaring insert is written with literals, because that is what // tells the engine what each column holds. -await conn.exec("INSERT (p:person {id: 0, name: 'n0'})") +await conn.exec("INSERT (p:person {id: 0, name: 'n0', on: DATE '2024-01-01'})") for (let start = 1; start < ROWS; start += BATCH) { const end = Math.min(start + BATCH, ROWS) const parts = [] - for (let ix = start; ix < end; ix++) parts.push(`(p${ix}:person {id: ${ix}, name: 'n${ix}'})`) + for (let ix = start; ix < end; ix++) { + parts.push(`(p${ix}:person {id: ${ix}, name: 'n${ix}', on: DATE '2024-01-01'})`) + } await conn.exec(`INSERT ${parts.join(', ')}`) } +// The same rows read by a connection in temporal mode, which is a +// second connection because the mode is settled when one is opened. A +// runtime without `Temporal` refuses that connection, which is the +// whole point of refusing it there, so the case is left out rather than +// measured as a failure. +const temporal = + typeof globalThis.Temporal === 'undefined' + ? null + : await connect(join(dir, 'bench.zu1'), { temporal: true }) + /// The fastest of `REPEATS` runs, in milliseconds, after one warmup. /// /// The fastest rather than the mean or the median, because everything @@ -83,6 +99,26 @@ const cases = [ run: () => conn.query('MATCH (p:person) RETURN p.id AS id', null, { bigIntMode: 'number' }), }, + { + // A DATE as the class this client registers, which is one object + // holding one integer. The number to read the next one against. + name: 'scan, one DATE column', + per: 'row', + run: () => conn.query('MATCH (p:person) RETURN p.on AS on'), + }, + ...(temporal + ? [ + { + // The same column as a `Temporal.PlainDate`, which is the + // three property reads that find the constructor, the civil + // calendar arithmetic that turns a day count into a date, and + // whatever the runtime's own class costs to construct. + name: 'scan, one DATE as Temporal', + per: 'row', + run: () => temporal.query('MATCH (p:person) RETURN p.on AS on'), + }, + ] + : []), { name: 'scan, whole nodes', per: 'row', @@ -163,5 +199,6 @@ for (const { name, per, run } of cases) { ) } +temporal?.close() conn.close() await rm(dir, { recursive: true, force: true }) diff --git a/binding.d.cts b/binding.d.cts index b34343c..2f0e1a8 100644 --- a/binding.d.cts +++ b/binding.d.cts @@ -1,6 +1,70 @@ /* auto-generated by NAPI-RS */ /* eslint-disable */ +/** + * `Temporal.PlainDate`, on a program that has it. + * + * Asked of `globalThis` rather than imported, because `Temporal` + * reached Stage 4 in March 2026 and which `lib` declares it is + * different in every version of TypeScript that has shipped since. A + * program compiling against a `lib` that has `Temporal` gets the real + * type here and is checked against it. One compiling against a `lib` + * that does not gets `unknown`, which needs a cast at the call site and + * is the truth: this client cannot promise a type the compiler has + * never heard of, and it should not fail to compile for saying so. + */ +export type ZuPlainDate = typeof globalThis extends { + Temporal: { PlainDate: new (...args: any[]) => infer Value } +} + ? Value + : unknown + +/** `Temporal.PlainTime`, on the terms [[ZuPlainDate]] gives. */ +export type ZuPlainTime = typeof globalThis extends { + Temporal: { PlainTime: new (...args: any[]) => infer Value } +} + ? Value + : unknown + +/** `Temporal.PlainDateTime`, on the terms [[ZuPlainDate]] gives. */ +export type ZuPlainDateTime = typeof globalThis extends { + Temporal: { PlainDateTime: new (...args: any[]) => infer Value } +} + ? Value + : unknown + +/** `Temporal.ZonedDateTime`, on the terms [[ZuPlainDate]] gives. */ +export type ZuZonedDateTime = typeof globalThis extends { + Temporal: { ZonedDateTime: new (...args: any[]) => infer Value } +} + ? Value + : unknown + +/** `Temporal.Duration`, on the terms [[ZuPlainDate]] gives. Named for + * the standard's class rather than for this client's `ZuDuration`, + * which is the other one. */ +export type ZuTemporalDuration = typeof globalThis extends { + Temporal: { Duration: new (...args: any[]) => infer Value } +} + ? Value + : unknown + +/** + * Every `Temporal` value this client understands, and nothing at all on + * a program whose `lib` has no `Temporal`. + * + * Nothing rather than `unknown` there, because this one is a member of + * a union: `unknown` in a union swallows it and would turn every row + * and every parameter into `unknown` for everybody. `never` in a union + * vanishes, so a program without `Temporal` types sees exactly what it + * saw before this existed. + */ +export type ZuTemporalValue = typeof globalThis extends { + Temporal: { Instant: new (...args: any[]) => infer Instant } +} + ? Instant | ZuPlainDate | ZuPlainTime | ZuPlainDateTime | ZuZonedDateTime | ZuTemporalDuration + : never + /** * A value a statement can hold, going out. * @@ -10,6 +74,11 @@ * as a number would be a count you cannot trust. `bigIntMode` changes * that for a statement or for a connection, with the hazard it * documents. + * + * A date, a time, a timestamp and a duration are the four classes by + * default and `Temporal` values on a connection opened with + * `{ temporal: true }`. A time with an offset is the exception in both + * directions: `Temporal` has no type for one, so it stays a `ZuTime`. */ export type ZuValue = | null @@ -24,6 +93,7 @@ export type ZuValue = | ZuTime | ZuTimestamp | ZuDuration + | ZuTemporalValue | ZuValue[] | { [field: string]: ZuValue } @@ -32,7 +102,11 @@ export type ZuValue = * * Wider than what comes out, because a `number` that is whole binds as * INT64 and `undefined` binds as null, which is what makes an optional - * field of a plain object pass straight through. + * field of a plain object pass straight through. A `Temporal` value + * binds as the zu value it is on every connection, whether or not the + * 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. */ export type ZuParam = | null @@ -45,6 +119,7 @@ export type ZuParam = | ZuTime | ZuTimestamp | ZuDuration + | ZuTemporalValue | ZuParam[] | { [field: string]: ZuParam } @@ -357,6 +432,19 @@ export declare class ZuCursor { export declare class ZuDate { days: number constructor(days: number) + /** + * The same day as a `Temporal.PlainDate`. + * + * For the program that wants one value converted rather than all of + * them, which is the common one: a result is read for its ids and + * its names and then formats the one date it is going to show. A + * connection opened with `{ temporal: true }` does this to every + * temporal value it gives back and this method is what it calls. + * + * Throws on a runtime that has no `Temporal`, naming the flag that + * turns it on. + */ + toTemporal(): ZuPlainDate /** * The same thing as a plain object, for the reason [`ZuNode::to_json`] * gives. @@ -384,6 +472,15 @@ export declare class ZuDuration { get months(): bigint /** The nanoseconds, which is zero for a year-month duration. */ get nanos(): bigint + /** + * The same length as a `Temporal.Duration`. + * + * A year-month one becomes months and a day-time one becomes + * seconds and nanoseconds, which are the fields that hold what zu + * stores without inventing the rest: `Temporal.Duration` can carry + * months and days at once and no zu duration ever does. + */ + toTemporal(): ZuTemporalDuration /** * The same thing as a plain object, for the reason [`ZuNode::to_json`] * gives. @@ -456,6 +553,14 @@ export declare class ZuTime { offset?: number constructor(nanos: bigint, offset?: number | undefined | null) get nanos(): bigint + /** + * The same time as a `Temporal.PlainTime`. + * + * A local time only. A time with an offset has no `Temporal` type + * at all, so one throws here rather than losing the offset, and the + * message says why. + */ + toTemporal(): ZuPlainTime /** * The same thing as a plain object, for the reason [`ZuNode::to_json`] * gives. @@ -476,6 +581,16 @@ export declare class ZuTimestamp { offset?: number constructor(nanos: bigint, offset?: number | undefined | null) get nanos(): bigint + /** + * The same instant as a `Temporal.PlainDateTime` for a local one + * and a `Temporal.ZonedDateTime` for a zoned one. + * + * The zone of a zoned one is the offset it was written at, spelled + * `+02:00`, because that is what the engine stores. A named zone is + * a rule that changes under a stored value when the zone database + * is updated, so no value here has ever had one. + */ + toTemporal(): ZuPlainDateTime | ZuZonedDateTime /** * The same thing as a plain object, for the reason [`ZuNode::to_json`] * gives. @@ -527,6 +642,27 @@ export interface ConnectOptions { * say otherwise again for itself. */ bigIntMode?: ZuBigIntMode + /** + * Gives back `Temporal` values rather than this client's four + * temporal classes, for every statement on this connection. + * + * Refused here, when the runtime has no `Temporal`, rather than at + * the first row that happens to hold a date: a program that asked + * for this and was quietly given something else would find out on + * the one code path its tests did not cover. Node 26 and the + * current browsers have `Temporal`, Node 24 has it behind + * `--harmony-temporal`, and a program that cannot be sure of its + * runtime uses `toTemporal()` on the value it wants instead. + * + * On the connection and not on a statement, because both spellings + * are exact and a program picks the one it wants to read for as + * long as it lives, where `bigIntMode` is a trade a single query + * makes. + * + * A time with an offset keeps its class either way, because + * `Temporal` has no type for one. + */ + temporal?: boolean } /** The version of the client. */ diff --git a/package.json b/package.json index 81e4883..be708a4 100644 --- a/package.json +++ b/package.json @@ -62,9 +62,11 @@ "build": "napi build --platform --release --js binding.cjs --dts binding.d.cts", "build:debug": "napi build --platform --js binding.cjs --dts binding.d.cts", "test": "node --test \"test/*.test.mjs\"", + "test:temporal": "node --harmony-temporal --test \"test/*.test.mjs\"", "check:types": "tsc --noEmit --project test/types/tsconfig.json", "check:package": "attw --pack .", - "bench": "node bench/query.mjs" + "bench": "node bench/query.mjs", + "bench:temporal": "node --harmony-temporal bench/query.mjs" }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.2", diff --git a/src/conn.rs b/src/conn.rs index 5d928a5..97c6e14 100644 --- a/src/conn.rs +++ b/src/conn.rs @@ -28,7 +28,8 @@ use zudb::{Config, Database, DiagnosticRecord, Interrupt, ZuError}; use crate::cancel::Watch; use crate::error::{aborted, raise, usage}; use crate::stream::{self, Started, ZuCursor}; -use crate::value::{Ints, Shape, from_js, to_js}; +use crate::temporal; +use crate::value::{Ints, Shape, Spelling, from_js, to_js}; /// What a connection can be opened with. /// @@ -50,6 +51,25 @@ pub struct ConnectOptions { /// say otherwise again for itself. #[napi(ts_type = "ZuBigIntMode")] pub big_int_mode: Option, + /// Gives back `Temporal` values rather than this client's four + /// temporal classes, for every statement on this connection. + /// + /// Refused here, when the runtime has no `Temporal`, rather than at + /// the first row that happens to hold a date: a program that asked + /// for this and was quietly given something else would find out on + /// the one code path its tests did not cover. Node 26 and the + /// current browsers have `Temporal`, Node 24 has it behind + /// `--harmony-temporal`, and a program that cannot be sure of its + /// runtime uses `toTemporal()` on the value it wants instead. + /// + /// On the connection and not on a statement, because both spellings + /// are exact and a program picks the one it wants to read for as + /// long as it lives, where `bigIntMode` is a trade a single query + /// makes. + /// + /// A time with an offset keeps its class either way, because + /// `Temporal` has no type for one. + pub temporal: Option, } /// One connection to one database. @@ -81,9 +101,9 @@ pub struct Connection { /// to the session and is the same one for the connection's whole /// life, so once is enough. interrupt: Interrupt, - /// How this connection's statements spell INT64, unless one of them - /// asks for the other spelling. - ints: Ints, + /// How this connection's statements spell the values they give + /// back, unless one of them asks for something else. + spelling: Spelling, path: String, read_only: bool, } @@ -94,13 +114,23 @@ pub struct Connection { /// program expects and what every embedded database does. A read-only /// connection never creates anything. #[napi(ts_return_type = "Promise")] -pub fn connect(path: String, options: Option) -> AsyncTask { - AsyncTask::new(ConnectTask { path, options }) +pub fn connect(env: &Env, path: String, options: Option) -> AsyncTask { + // Whether this runtime has `Temporal` is a question only the thread + // that owns the runtime may ask, so it is asked here and carried to + // the thread that opens the database, where the answer decides + // whether there is anything to open. + let has_temporal = temporal::present(env).unwrap_or(false); + AsyncTask::new(ConnectTask { + path, + options, + has_temporal, + }) } pub struct ConnectTask { path: String, options: Option, + has_temporal: bool, } impl<'task> ScopedTask<'task> for ConnectTask { @@ -127,6 +157,22 @@ impl<'task> ScopedTask<'task> for ConnectTask { }, None => Ints::default(), }; + // And for the same reason: a program that asked for `Temporal` + // on a runtime without one is a program that is not going to + // work, and hearing so from the connect is hearing it before + // anything has been written. + let wants_temporal = self + .options + .as_ref() + .and_then(|options| options.temporal) + .unwrap_or(false); + if wants_temporal && !self.has_temporal { + return Ok(Err(Failure::Usage(temporal::MISSING.to_string()))); + } + let spelling = Spelling { + ints, + temporal: wants_temporal, + }; let mut config = Config::new().read_only(read_only); if let Some(options) = &self.options { if let Some(limit) = &options.memory_limit { @@ -138,7 +184,7 @@ impl<'task> ScopedTask<'task> for ConnectTask { } } Ok(open(PathBuf::from(&self.path), read_only, config) - .map(|opened| Opened { ints, ..opened }) + .map(|opened| Opened { spelling, ..opened }) .map_err(Failure::Engine)) } @@ -148,7 +194,7 @@ impl<'task> ScopedTask<'task> for ConnectTask { interrupt: opened.conn.interrupt(), inner: Arc::new(Mutex::new(Some(opened.conn))), alive: Arc::new(AtomicBool::new(true)), - ints: opened.ints, + spelling: opened.spelling, path: opened.path, read_only: opened.read_only, } @@ -183,7 +229,7 @@ fn wire_disposal(env: &Env, instance: &mut ClassInstance<'_, Connection>) -> Res pub struct Opened { conn: zudb::Connection, - ints: Ints, + spelling: Spelling, path: String, read_only: bool, } @@ -204,7 +250,7 @@ fn open(path: PathBuf, read_only: bool, config: Config) -> std::result::Result (params, ints, batch_rows, watch, None), - Err(message) => (Vec::new(), self.ints, None, None, Some(message)), + let (params, spelling, batch_rows, watch, refused) = match bound { + Ok((params, spelling, batch_rows, watch)) => { + (params, spelling, batch_rows, watch, None) + } + Err(message) => (Vec::new(), self.spelling, None, None, Some(message)), }; stream::open( Started { @@ -314,7 +362,7 @@ impl Connection { alive: Arc::clone(&self.alive), statement, params, - ints, + spelling, batch_rows, guard: watch.as_ref().map(Watch::guard), }, @@ -343,30 +391,44 @@ impl Connection { // the thread that owns the runtime may do. So is adding the // listener the signal is watched through. let bound = if self.alive.load(Ordering::Acquire) { - int_mode(options.as_ref(), self.ints).and_then(|ints| { + self.spell(options.as_ref()).and_then(|spelling| { Ok(( bind(env, params)?, - ints, + spelling, watch(env, options, self.interrupt.clone())?, )) }) } else { Err(CLOSED.to_string()) }; - let (params, ints, watch, refused) = match bound { - Ok((params, ints, watch)) => (params, ints, watch, None), - Err(message) => (Vec::new(), self.ints, None, Some(message)), + let (params, spelling, watch, refused) = match bound { + Ok((params, spelling, watch)) => (params, spelling, watch, None), + Err(message) => (Vec::new(), self.spelling, None, Some(message)), }; QueryTask { inner: Arc::clone(&self.inner), statement, params, - ints, + spelling, watch, refused, } } + /// How this statement spells the values it gives back, which is the + /// connection's own unless the statement said otherwise. + /// + /// Only the integers can be said otherwise. `Temporal` is a decision + /// about how a whole program reads dates and both spellings are + /// exact, where the integer modes are a trade one query makes and + /// the next one does not. + fn spell(&self, options: Option<&Object<'_>>) -> std::result::Result { + Ok(Spelling { + ints: int_mode(options, self.spelling.ints)?, + ..self.spelling + }) + } + /// Closes the connection and releases the database. /// /// Closing twice does nothing the second time, which is what makes @@ -576,8 +638,8 @@ pub struct QueryTask { inner: Arc>>, statement: String, params: Vec<(String, Value)>, - /// How this statement spells the INT64s it gives back. - ints: Ints, + /// How this statement spells the values it gives back. + spelling: Spelling, /// The signal watching this statement, when the caller gave one. watch: Option, /// Why this statement is not going to run, when it is not. @@ -626,7 +688,7 @@ impl QueryTask { .iter() .map(|(name, value)| (name.as_str(), value.clone())) .collect(); - let shape = Shape::of(conn.session_mut().catalog(), self.ints); + let shape = Shape::of(conn.session_mut().catalog(), self.spelling); let result = conn.query_with(&self.statement, ¶ms); if let Some(watch) = &self.watch { watch.leave(); diff --git a/src/lib.rs b/src/lib.rs index 801cf8c..ca1ac23 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ mod cancel; mod conn; mod error; mod stream; +mod temporal; mod value; /// The version of the client. diff --git a/src/stream.rs b/src/stream.rs index ab9ac21..376f014 100644 --- a/src/stream.rs +++ b/src/stream.rs @@ -35,7 +35,7 @@ use zudb::{Batch, Flow, Streamed, ZuError}; use crate::cancel::{Guard, Watch}; use crate::conn::{CLOSED, Failure, beside, failed, notices}; -use crate::value::{Ints, Shape, to_js}; +use crate::value::{Shape, Spelling, to_js}; /// How many batches may sit between the statement and the reader. /// @@ -197,7 +197,7 @@ pub struct Started { pub alive: Arc, pub statement: String, pub params: Vec<(String, Value)>, - pub ints: Ints, + pub spelling: Spelling, pub batch_rows: Option, pub guard: Option, } @@ -607,7 +607,7 @@ impl Started { guard.leave(); return Err(Failure::Aborted); } - let shape = Arc::new(Shape::of(conn.session_mut().catalog(), self.ints)); + let shape = Arc::new(Shape::of(conn.session_mut().catalog(), self.spelling)); let params: Vec<(&str, Value)> = self .params .iter() diff --git a/src/temporal.rs b/src/temporal.rs new file mode 100644 index 0000000..3117d6c --- /dev/null +++ b/src/temporal.rs @@ -0,0 +1,459 @@ +//! `Temporal`, for the runtimes that have it. +//! +//! zu's temporal values are counts: a date is days from 1970-01-01, a +//! time is nanoseconds from midnight, a timestamp is nanoseconds from +//! the epoch, and a duration is months or nanoseconds. The classes in +//! [`crate::value`] 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, and it reached Stage 4 in +//! March 2026. It is unflagged in Node 26 and the current browsers, and +//! Node 24 is still the active LTS and has it behind +//! `--harmony-temporal`, so a client that returned `Temporal` values by +//! default would be a client half its users cannot load. Hence the +//! opt-in: `{ temporal: true }` when connecting, refused there and then +//! on a runtime without one, and `toTemporal()` on each of the four +//! classes for a program that wants one value converted rather than all +//! of them. +//! +//! The other direction needs no opt-in. A `Temporal` value passed as a +//! parameter binds as the zu value it is, on every connection, because +//! recognizing one costs a property read and refusing one would be a +//! rule nobody could guess. +//! +//! Nothing here is cached. A `Temporal` value is a JavaScript object and +//! the constructors are properties of a JavaScript object, and neither +//! can be held by a struct that crosses to a threadpool thread, so each +//! conversion looks the constructor up: a global, a namespace and a +//! class, three property reads. That is the price of the opt-in and it +//! is paid per temporal value rather than per row. + +use napi::bindgen_prelude::*; +use napi::{Env, ValueType}; +use zu_common::{DurationKind, Temporal}; + +/// What a program asking for `Temporal` on a runtime without one is +/// told, wherever it asked. +pub const MISSING: &str = "this runtime has no Temporal: Node 26 and the current browsers have \ + it, Node 24 has it behind --harmony-temporal, and until then the \ + ZuDate, ZuTime, ZuTimestamp and ZuDuration classes are the spelling \ + there is"; + +/// What a zoned time is told, which is the one value zu holds and +/// `Temporal` has no type for. +pub const NO_ZONED_TIME: &str = "a time with an offset has no Temporal type: PlainTime is local \ + and ZonedDateTime carries a date, so this value stays a ZuTime \ + in temporal mode rather than losing its offset or gaining a day \ + nobody wrote"; + +/// Whether this runtime has `Temporal` at all. +pub fn present(env: &Env) -> Result { + Ok(namespace(env)?.is_some()) +} + +/// The `Temporal` namespace, or nothing on a runtime that has none. +/// +/// Read off the global each time rather than remembered, for the reason +/// the module comment gives. +fn namespace<'env>(env: &'env Env) -> Result>> { + let found: Unknown<'env> = env.get_global()?.get_named_property("Temporal")?; + match found.get_type()? { + ValueType::Object => Ok(Some(found.coerce_to_object()?)), + _ => Ok(None), + } +} + +/// The `Temporal` value for a zu one, or `None` for the zoned time that +/// has no `Temporal` type. +/// +/// The classes are reached through their constructors rather than +/// through `from`, because a constructor takes the fields as arguments +/// and `from` takes them as an object, and the object is an allocation +/// spent to say the same thing. +/// +/// A count outside what `Temporal` holds throws from the constructor +/// itself, which is a `RangeError` naming the field, and that is a +/// better message than one written here: zu's dates run to five million +/// years and `Temporal` stops at 271821 BCE, so the two disagree about +/// values no calendar has an opinion on either. +pub fn to_temporal<'env>(env: &'env Env, value: Temporal) -> Result>> { + let Some(temporal) = namespace(env)? else { + return Err(Error::new(Status::GenericFailure, MISSING)); + }; + let made = match value { + Temporal::Date(days) => { + let (year, month, day) = civil(i64::from(days)); + build(&temporal, "PlainDate", FnArgs::from((year, month, day)))? + } + Temporal::LocalTime(nanos) => { + let (hour, minute, second, milli, micro, nano) = clock(nanos); + build( + &temporal, + "PlainTime", + FnArgs::from((hour, minute, second, milli, micro, nano)), + )? + } + // The one zu holds and `Temporal` does not. Said with `None` + // rather than an error, because a result full of them should + // still arrive: the values that have a `Temporal` type get one + // and this keeps its class. + Temporal::ZonedTime { .. } => return Ok(None), + Temporal::LocalDatetime(nanos) => { + let (year, month, day) = civil(nanos.div_euclid(DAY)); + let (hour, minute, second, milli, micro, nano) = clock(nanos.rem_euclid(DAY)); + build( + &temporal, + "PlainDateTime", + FnArgs::from((year, month, day, hour, minute, second, milli, micro, nano)), + )? + } + // The instant and the offset, which is exactly what the value + // is. A named zone would be a rule that changes under a stored + // value when the zone database is updated, and the engine does + // not store one. + Temporal::ZonedDatetime { nanos, offset } => build( + &temporal, + "ZonedDateTime", + FnArgs::from((BigInt::from(nanos), zone(offset))), + )?, + Temporal::Duration(DurationKind::YearMonth, months) => { + build(&temporal, "Duration", FnArgs::from((0, months)))? + } + // Seconds and nanoseconds rather than nanoseconds alone, + // because a `Temporal.Duration` field is a JavaScript number and + // a count of nanoseconds passes 2^53 after fourteen weeks. + Temporal::Duration(DurationKind::DayTime, nanos) => build( + &temporal, + "Duration", + FnArgs::from((0, 0, 0, 0, 0, 0, nanos / SECOND, 0, 0, nanos % SECOND)), + )?, + }; + Ok(Some(made)) +} + +/// `new Temporal.(...)`. +fn build<'env, Args: JsValuesTupleIntoVec>( + temporal: &Object<'env>, + class: &str, + args: Args, +) -> Result> { + let class: Function<'env, Args, Unknown<'env>> = temporal.get_named_property(class)?; + class.new_instance(args) +} + +/// The zu value a `Temporal` one binds as, or `None` when the value is +/// not a `Temporal` one at all. +/// +/// Recognized by `Symbol.toStringTag`, which every `Temporal` class sets +/// to its own name, rather than by `instanceof`: the tag is one property +/// read for 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. +pub fn from_temporal(env: &Env, name: &str, value: &Unknown<'_>) -> Result> { + let Some(tag) = tag(env, value)? else { + return Ok(None); + }; + let Some(kind) = tag.strip_prefix("Temporal.") else { + return Ok(None); + }; + let object = Object::from_unknown(*value)?; + let bound = match kind { + "PlainDate" => Temporal::Date(days(name, &object)?), + "PlainTime" => Temporal::LocalTime(nanos_of(&object)?), + "PlainDateTime" => Temporal::LocalDatetime(epoch( + name, + i64::from(days(name, &object)?), + nanos_of(&object)?, + )?), + // The instant and the offset are read off the value rather than + // its fields, so the zone it carries can be a named one: what + // zu stores is the instant and how far from UTC it was written, + // and a name is exactly the part that cannot survive being + // stored. + "ZonedDateTime" => Temporal::ZonedDatetime { + nanos: since(name, &object)?, + offset: offset(name, &object)?, + }, + // UTC, because that is what an instant is. + "Instant" => Temporal::ZonedDatetime { + nanos: since(name, &object)?, + offset: 0, + }, + "Duration" => duration(name, &object)?, + other => { + return Err(refused(format!( + "parameter {name} is a Temporal.{other}, and the temporal values zu holds are a \ + date, a time, a timestamp and a duration" + ))); + } + }; + Ok(Some(bound)) +} + +/// The value's `Symbol.toStringTag`, when it has one that is a string. +fn tag(env: &Env, value: &Unknown<'_>) -> Result> { + // `Symbol` is a function and the well-known symbols hang off it as + // properties of that function. + let symbols: Function<'_, (), Unknown<'_>> = env.get_global()?.get_named_property("Symbol")?; + let key: Unknown<'_> = symbols.get_named_property("toStringTag")?; + if key.get_type()? != ValueType::Symbol { + return Ok(None); + } + let found: Unknown<'_> = Object::from_unknown(*value)?.get_property(key)?; + match found.get_type()? { + ValueType::String => Ok(Some(String::from_unknown(found)?)), + _ => Ok(None), + } +} + +/// The days from the epoch a `PlainDate` or a `PlainDateTime` stands +/// for. +/// +/// The calendar is checked rather than assumed. `year`, `month` and +/// `day` are the calendar's own fields, so a Hebrew date read as though +/// it were ISO is a different day rather than an unreadable one, and a +/// wrong day that stores cleanly is the worst kind of wrong. +fn days(name: &str, object: &Object<'_>) -> Result { + if let Some(calendar) = calendar(object)? + && calendar != "iso8601" + { + return Err(refused(format!( + "parameter {name} is in the {calendar} calendar, and zu stores a date as days from \ + 1970-01-01, which is the ISO calendar: convert it with .withCalendar('iso8601')" + ))); + } + let year: i32 = object.get_named_property("year")?; + let month: i64 = object.get_named_property("month")?; + let day: i64 = object.get_named_property("day")?; + Ok(civil_days(i64::from(year), month, day) as i32) +} + +/// What the value's calendar is called, when it can be got to say. +/// +/// Three ways of asking, because the proposal changed under the +/// runtimes and the runtimes are still where they were when it did. +/// `calendarId` is a string and is what the standard settled on; +/// `calendar` was a string for a while and an object with the name on +/// it before that, which is what Node 24 behind `--harmony-temporal` +/// still hands over. A value that answers none of them is taken as ISO, +/// because that is the default in every version of the proposal and a +/// refusal on a question nobody can answer helps nobody. +fn calendar(object: &Object<'_>) -> Result> { + let named: Unknown<'_> = object.get_named_property("calendarId")?; + if named.get_type()? == ValueType::String { + return Ok(Some(String::from_unknown(named)?)); + } + let calendar: Unknown<'_> = object.get_named_property("calendar")?; + match calendar.get_type()? { + ValueType::String => Ok(Some(String::from_unknown(calendar)?)), + ValueType::Object => { + let id: Unknown<'_> = calendar.coerce_to_object()?.get_named_property("id")?; + match id.get_type()? { + ValueType::String => Ok(Some(String::from_unknown(id)?)), + _ => Ok(None), + } + } + _ => Ok(None), + } +} + +/// The nanoseconds from midnight a `PlainTime` or a `PlainDateTime` +/// stands for. +fn nanos_of(object: &Object<'_>) -> Result { + let hour: i64 = object.get_named_property("hour")?; + let minute: i64 = object.get_named_property("minute")?; + let second: i64 = object.get_named_property("second")?; + let milli: i64 = object.get_named_property("millisecond")?; + let micro: i64 = object.get_named_property("microsecond")?; + let nano: i64 = object.get_named_property("nanosecond")?; + Ok(((hour * 60 + minute) * 60 + second) * SECOND + milli * 1_000_000 + micro * 1_000 + nano) +} + +/// The nanoseconds from the epoch, from a day and a time of day. +fn epoch(name: &str, days: i64, nanos: i64) -> Result { + days.checked_mul(DAY) + .and_then(|start| start.checked_add(nanos)) + .ok_or_else(|| { + refused(format!( + "parameter {name} is outside what a zu timestamp holds, which is nanoseconds in \ + an INT64 and so runs from 1677-09-21 to 2262-04-11" + )) + }) +} + +/// The nanoseconds from the epoch an instant carries. +fn since(name: &str, object: &Object<'_>) -> Result { + let nanos: BigInt = object.get_named_property("epochNanoseconds")?; + let (nanos, lossless) = nanos.get_i64(); + match lossless { + true => Ok(nanos), + false => Err(refused(format!( + "parameter {name} is outside what a zu timestamp holds, which is nanoseconds in an \ + INT64 and so runs from 1677-09-21 to 2262-04-11" + ))), + } +} + +/// The offset from UTC in minutes a zoned value was written at. +/// +/// Read as nanoseconds and divided, because that is the field that is a +/// number rather than a string nobody should be parsing. An offset that +/// is not a whole number of minutes is refused: zu stores minutes, and +/// the zones that had seconds in their offsets stopped in 1972. +fn offset(name: &str, object: &Object<'_>) -> Result { + let nanos: i64 = object.get_named_property("offsetNanoseconds")?; + let minutes = nanos / (60 * SECOND); + if nanos % (60 * SECOND) != 0 || i16::try_from(minutes).is_err() { + let offset: String = object.get_named_property("offset")?; + return Err(refused(format!( + "parameter {name} is at {offset} from UTC, and zu stores an offset as whole minutes" + ))); + } + Ok(minutes as i16) +} + +/// The zu duration a `Temporal.Duration` is. +/// +/// The two kinds do not mix, here or anywhere else in zu, because no +/// number of days is a month: a value holding both would have to invent +/// an answer for one month after 31 January. A `Temporal.Duration` can +/// hold both, so the one that does is refused rather than rounded into +/// one of them. +/// +/// A week is seven days and a day is twenty-four hours, which is what +/// `Temporal` itself assumes when it converts one without a date to +/// hang it on. +fn duration(name: &str, object: &Object<'_>) -> Result { + let field = |field: &str| -> Result { object.get_named_property(field) }; + let months = field("years")? * 12 + field("months")?; + let days = field("weeks")? * 7 + field("days")?; + let nanos = (((days * 24 + field("hours")?) * 60 + field("minutes")?) * 60 + field("seconds")?) + * SECOND + + field("milliseconds")? * 1_000_000 + + field("microseconds")? * 1_000 + + field("nanoseconds")?; + match (months, nanos) { + (0, nanos) => Ok(Temporal::Duration(DurationKind::DayTime, nanos)), + (months, 0) => Ok(Temporal::Duration(DurationKind::YearMonth, months)), + _ => Err(refused(format!( + "parameter {name} counts both months and days, and a zu duration counts one or the \ + other: no number of days is a month, so a value holding both would have to invent an \ + answer for one month after 31 January" + ))), + } +} + +/// A refusal, which is a mistake in the calling program rather than +/// anything the engine has an opinion about. `InvalidArg` is how +/// [`crate::conn`] tells one from a boundary failure. +fn refused(message: String) -> Error { + Error::new(Status::InvalidArg, message) +} + +/// The nanoseconds in a second and in a day. +const SECOND: i64 = 1_000_000_000; +const DAY: i64 = 86_400 * SECOND; + +/// The offset written the way a `Temporal` time zone is named. +fn zone(minutes: i16) -> String { + let sign = if minutes < 0 { '-' } else { '+' }; + let minutes = minutes.unsigned_abs(); + format!("{sign}{:02}:{:02}", minutes / 60, minutes % 60) +} + +/// A time of day split into the fields a `Temporal.PlainTime` is built +/// from. +fn clock(nanos: i64) -> (i64, i64, i64, i64, i64, i64) { + ( + nanos / (3600 * SECOND), + nanos / (60 * SECOND) % 60, + nanos / SECOND % 60, + nanos / 1_000_000 % 1_000, + nanos / 1_000 % 1_000, + nanos % 1_000, + ) +} + +/// The year, month and day at `days` from 1970-01-01. +/// +/// Howard Hinnant's `civil_from_days`, which is the shortest correct +/// one: it counts from 0000-03-01 rather than from January, so the leap +/// day is the last day of the year and every month from there has a +/// length that fits one linear formula. Proleptic Gregorian in both +/// directions, which is what the ISO calendar is. +fn civil(days: i64) -> (i32, i64, i64) { + let shifted = days + 719_468; + let era = if shifted >= 0 { + shifted + } else { + shifted - 146_096 + } / 146_097; + let day_of_era = shifted - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let shifted_month = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * shifted_month + 2) / 5 + 1; + let month = if shifted_month < 10 { + shifted_month + 3 + } else { + shifted_month - 9 + }; + ((year + i64::from(month <= 2)) as i32, month, day) +} + +/// The days from 1970-01-01 to a year, month and day, which is +/// [`civil`] run backwards and Hinnant's `days_from_civil`. +fn civil_days(year: i64, month: i64, day: i64) -> i64 { + let year = year - i64::from(month <= 2); + let era = if year >= 0 { year } else { year - 399 } / 400; + let year_of_era = year - era * 400; + let shifted_month = if month > 2 { month - 3 } else { month + 9 }; + let day_of_year = (153 * shifted_month + 2) / 5 + day - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + era * 146_097 + day_of_era - 719_468 +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The two directions agree, over a range wide enough to cross + /// every leap rule there is: the four year one, the hundred year + /// one, and the four hundred year one that made 2000 a leap year + /// and 1900 not. + #[test] + fn civil_round_trips() { + for days in (-1_000_000..1_000_000).step_by(7) { + let (year, month, day) = civil(days); + assert_eq!(civil_days(i64::from(year), month, day), days); + } + } + + #[test] + fn civil_knows_the_dates_everybody_checks() { + assert_eq!(civil(0), (1970, 1, 1)); + assert_eq!(civil(-1), (1969, 12, 31)); + assert_eq!(civil(11_016), (2000, 2, 29)); + assert_eq!(civil_days(2000, 2, 29), 11_016); + assert_eq!(civil_days(1900, 3, 1) - civil_days(1900, 2, 28), 1); + assert_eq!(civil_days(1, 1, 1), -719_162); + } + + #[test] + fn a_time_splits_into_its_fields() { + assert_eq!(clock(0), (0, 0, 0, 0, 0, 0)); + assert_eq!(clock(DAY - 1), (23, 59, 59, 999, 999, 999)); + assert_eq!(clock(13 * 3600 * SECOND + 1), (13, 0, 0, 0, 0, 1)); + } + + #[test] + fn an_offset_is_named_the_way_a_zone_is() { + assert_eq!(zone(0), "+00:00"); + assert_eq!(zone(120), "+02:00"); + assert_eq!(zone(-330), "-05:30"); + assert_eq!(zone(-1), "-00:01"); + } +} diff --git a/src/value.rs b/src/value.rs index 824250b..ed96bdb 100644 --- a/src/value.rs +++ b/src/value.rs @@ -32,6 +32,7 @@ use zudb::query::Value; use zudb::zu1::catalog::Catalog; use crate::error::usage; +use crate::temporal; /// How an INT64 is spelled on the way out. /// @@ -68,21 +69,38 @@ impl Ints { } } +/// How a statement spells the values it gives back. +/// +/// Two decisions, both made where the connection is opened and one of +/// them changeable per statement. They travel together because they +/// travel the same way: from the call, through the task, to the thread +/// the statement runs on, and back to the row that is being built. +#[derive(Clone, Copy, Default)] +pub struct Spelling { + /// How an INT64 comes back. + pub ints: Ints, + /// Whether a temporal value comes back as a `Temporal` one rather + /// than as one of this client's four classes. Off unless the + /// connection asked for it, and asking on a runtime without + /// `Temporal` fails at the connect rather than at the first row. + pub temporal: bool, +} + /// What a result needs on the way out. /// /// The table names are the statement's, and so is the spelling of its -/// integers, so both are settled once where the connection is held and +/// values, so both are settled once where the connection is held and /// then read by every value of every row. pub struct Shape { names: Names, - ints: Ints, + spelling: Spelling, } impl Shape { - pub fn of(catalog: &Catalog, ints: Ints) -> Shape { + pub fn of(catalog: &Catalog, spelling: Spelling) -> Shape { Shape { names: Names::of(catalog), - ints, + spelling, } } } @@ -254,6 +272,21 @@ impl ZuDate { ZuDate { days } } + /// The same day as a `Temporal.PlainDate`. + /// + /// For the program that wants one value converted rather than all of + /// them, which is the common one: a result is read for its ids and + /// its names and then formats the one date it is going to show. A + /// connection opened with `{ temporal: true }` does this to every + /// temporal value it gives back and this method is what it calls. + /// + /// Throws on a runtime that has no `Temporal`, naming the flag that + /// turns it on. + #[napi(js_name = "toTemporal", ts_return_type = "ZuPlainDate")] + pub fn to_temporal<'env>(&self, env: &'env Env) -> Result> { + converted(env, Temporal::Date(self.days)) + } + /// The same thing as a plain object, for the reason [`ZuNode::to_json`] /// gives. #[napi(js_name = "toJSON")] @@ -292,6 +325,25 @@ impl ZuTime { BigInt::from(self.nanos) } + /// The same time as a `Temporal.PlainTime`. + /// + /// A local time only. A time with an offset has no `Temporal` type + /// at all, so one throws here rather than losing the offset, and the + /// message says why. + #[napi(js_name = "toTemporal", ts_return_type = "ZuPlainTime")] + pub fn to_temporal<'env>(&self, env: &'env Env) -> Result> { + match self.offset { + Some(offset) => converted( + env, + Temporal::ZonedTime { + nanos: self.nanos, + offset: offset as i16, + }, + ), + None => converted(env, Temporal::LocalTime(self.nanos)), + } + } + /// The same thing as a plain object, for the reason [`ZuNode::to_json`] /// gives. #[napi(js_name = "toJSON")] @@ -331,6 +383,30 @@ impl ZuTimestamp { BigInt::from(self.nanos) } + /// The same instant as a `Temporal.PlainDateTime` for a local one + /// and a `Temporal.ZonedDateTime` for a zoned one. + /// + /// The zone of a zoned one is the offset it was written at, spelled + /// `+02:00`, because that is what the engine stores. A named zone is + /// a rule that changes under a stored value when the zone database + /// is updated, so no value here has ever had one. + #[napi( + js_name = "toTemporal", + ts_return_type = "ZuPlainDateTime | ZuZonedDateTime" + )] + pub fn to_temporal<'env>(&self, env: &'env Env) -> Result> { + match self.offset { + Some(offset) => converted( + env, + Temporal::ZonedDatetime { + nanos: self.nanos, + offset: offset as i16, + }, + ), + None => converted(env, Temporal::LocalDatetime(self.nanos)), + } + } + /// The same thing as a plain object, for the reason [`ZuNode::to_json`] /// gives. #[napi(js_name = "toJSON")] @@ -388,6 +464,21 @@ impl ZuDuration { BigInt::from(self.nanos) } + /// The same length as a `Temporal.Duration`. + /// + /// A year-month one becomes months and a day-time one becomes + /// seconds and nanoseconds, which are the fields that hold what zu + /// stores without inventing the rest: `Temporal.Duration` can carry + /// months and days at once and no zu duration ever does. + #[napi(js_name = "toTemporal", ts_return_type = "ZuTemporalDuration")] + pub fn to_temporal<'env>(&self, env: &'env Env) -> Result> { + let kind = match self.kind { + "yearMonth" => Temporal::Duration(DurationKind::YearMonth, self.months), + _ => Temporal::Duration(DurationKind::DayTime, self.nanos), + }; + converted(env, kind) + } + /// The same thing as a plain object, for the reason [`ZuNode::to_json`] /// gives. #[napi(js_name = "toJSON")] @@ -400,6 +491,27 @@ impl ZuDuration { } } +/// What `toTemporal()` gives back, which is the `Temporal` value or the +/// reason there is not one. +/// +/// The reason is a `ZuUsageError` rather than a plain throw, because it +/// is the same kind of thing every other refusal in this client is: the +/// caller asked for something the value cannot be, and the answer names +/// the value rather than the line it happened on. +fn converted(env: &Env, value: Temporal) -> Result> { + // A runtime without `Temporal` at all, which the connect option + // catches before a statement runs and this method cannot: it is + // called on a value that already exists, so the first it hears of + // the runtime is now. + if !temporal::present(env)? { + return Err(usage(env, temporal::MISSING)); + } + match temporal::to_temporal(env, value)? { + Some(made) => Ok(made), + None => Err(usage(env, temporal::NO_ZONED_TIME)), + } +} + fn year_month(months: i64) -> ZuDuration { ZuDuration { kind: "yearMonth", @@ -432,7 +544,7 @@ pub fn to_js<'env>( match value { Value::Null => Null.into_unknown(env), Value::Bool(b) => (*b).into_unknown(env), - Value::Int(n) => int(env, column, *n, shape.ints), + Value::Int(n) => int(env, column, *n, shape.spelling.ints), Value::Float(f) => (*f).into_unknown(env), Value::Str(s) => s.as_str().into_unknown(env), Value::Node { table, offset } => node(*table, *offset, &shape.names) @@ -460,7 +572,7 @@ pub fn to_js<'env>( } object.into_unknown(env) } - Value::Temporal(t) => temporal(env, *t), + Value::Temporal(t) => moment(env, *t, shape.spelling.temporal), Value::Path(walk) => path(env, walk, &shape.names), // The three the executor keeps to itself. A chain is settled // into an edge list before any value leaves the pipeline, and a @@ -555,7 +667,24 @@ fn path<'env>(env: &'env Env, walk: &[Value], names: &Names) -> Result Result> { +/// A temporal value, spelled the way this statement was asked to spell +/// them. +/// +/// A connection that asked for `Temporal` gets it for every value that +/// has a `Temporal` type, and the one that does not, which is a time +/// with an offset, keeps its class. That is a mode with a hole in it and +/// the hole is the standard's: `PlainTime` is local and `ZonedDateTime` +/// carries a date, so the alternatives are dropping the offset or +/// inventing a day, and a class the caller already knows how to read is +/// better than either. +fn moment(env: &Env, value: Temporal, wanted: bool) -> Result> { + if wanted && let Some(made) = temporal::to_temporal(env, value)? { + return Ok(made); + } + as_class(env, value) +} + +fn as_class(env: &Env, value: Temporal) -> Result> { match value { Temporal::Date(days) => ZuDate { days }.into_instance(env)?.into_unknown(env), Temporal::LocalTime(nanos) => ZuTime { @@ -654,6 +783,13 @@ fn from_object(env: &Env, name: &str, value: Unknown<'_>) -> Result { } return Ok(Value::List(items)); } + // After the array and before the record, because a `Temporal` value + // has no own enumerable properties at all: read as a record it + // would bind as `{}` and compare against nothing, which is the one + // outcome worse than a refusal. + if let Some(moment) = temporal::from_temporal(env, name, &value)? { + return Ok(Value::Temporal(moment)); + } // A plain object is a record, which is the one mapping that reads // the same in both directions: a record comes back as an object // with the same field names. diff --git a/test/temporal.test.mjs b/test/temporal.test.mjs new file mode 100644 index 0000000..b0f608b --- /dev/null +++ b/test/temporal.test.mjs @@ -0,0 +1,300 @@ +// Temporal, both ways. +// +// The file runs on a runtime that has `Temporal` and on one that does +// not, and it asserts something in both cases, because both are +// supported and the second one is most of them: Node 24 is the active +// LTS and has `Temporal` only behind `--harmony-temporal`. What a +// runtime without one has to do is refuse clearly and keep working, and +// that is as much a feature as the conversions are. +// +// npm test the runtime as it comes +// npm run test:temporal the same tests with the flag on +// +// The values are asserted field by field rather than by their printed +// form, because the proposal changed under the runtimes twice and the +// fields are the part that did not move. + +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import test from 'node:test' + +import { connect, ZuDate, ZuDuration, ZuTime, ZuTimestamp } from 'zudb' + +import { fresh, isZuError } from './helper.mjs' + +const HAS_TEMPORAL = typeof globalThis.Temporal !== 'undefined' +const has = { skip: HAS_TEMPORAL ? false : 'this runtime has no Temporal' } +const hasNot = { skip: HAS_TEMPORAL ? 'this runtime has Temporal' : false } + +// 2023-11-14T22:13:20.123456789Z, which is a whole second of the epoch +// with every one of the nine digits under it different, so a conversion +// that drops the microseconds or rounds the nanoseconds says so. +const INSTANT = 1700000000123456789n +const DAYS = 19723 // 2024-01-01 + +// What a value calls itself, which is the one way of asking that every +// version of the proposal answers the same. +function tag(value) { + return Object.prototype.toString.call(value) +} + +async function one(conn, value) { + const rows = await conn.query('RETURN $v AS v', { v: value }) + return rows[0].v +} + +test('a connection that asked for Temporal gets Temporal values', has, async (t) => { + const { conn } = await fresh(t, { temporal: true }) + + const date = await one(conn, new ZuDate(DAYS)) + assert.equal(tag(date), '[object Temporal.PlainDate]') + assert.deepEqual([date.year, date.month, date.day], [2024, 1, 1]) + + const time = await one(conn, new ZuTime(3600000000001n, null)) + assert.equal(tag(time), '[object Temporal.PlainTime]') + assert.deepEqual([time.hour, time.minute, time.nanosecond], [1, 0, 1]) + + const stamp = await one(conn, new ZuTimestamp(INSTANT, null)) + assert.equal(tag(stamp), '[object Temporal.PlainDateTime]') + assert.deepEqual( + [stamp.year, stamp.month, stamp.day, stamp.hour, stamp.minute, stamp.second], + [2023, 11, 14, 22, 13, 20], + ) + assert.deepEqual([stamp.millisecond, stamp.microsecond, stamp.nanosecond], [123, 456, 789]) + + const zoned = await one(conn, new ZuTimestamp(INSTANT, -480)) + assert.equal(tag(zoned), '[object Temporal.ZonedDateTime]') + assert.equal(zoned.epochNanoseconds, INSTANT) + // The offset it was written at and never a named zone, because a name + // is a rule that changes under a stored value when the zone database + // is updated and the engine stores no name. + assert.equal(zoned.offset, '-08:00') + + const months = await one(conn, ZuDuration.ofMonths(14n)) + assert.equal(tag(months), '[object Temporal.Duration]') + assert.deepEqual([months.years, months.months, months.days], [0, 14, 0]) + + const nanos = await one(conn, ZuDuration.ofNanos(90000000005n)) + assert.deepEqual([nanos.months, nanos.seconds, nanos.nanoseconds], [0, 90, 5]) +}) + +test('a time with an offset keeps its class, and says why', has, async (t) => { + const { conn } = await fresh(t, { temporal: true }) + + // The one hole in the mode, and it is the standard's: PlainTime is + // local and ZonedDateTime carries a date, so the alternatives are + // dropping the offset or inventing a day. + const zoned = await one(conn, new ZuTime(3600000000001n, 120)) + assert.ok(zoned instanceof ZuTime) + assert.equal(zoned.offset, 120) + assert.throws( + () => zoned.toTemporal(), + (err) => isZuError(err, 'ZuUsageError') && /no Temporal type/.test(err.message), + ) + + // And the local one beside it converts, so this is a hole and not a + // class that stopped working. + const local = await one(conn, new ZuTime(3600000000001n, null)) + assert.equal(tag(local), '[object Temporal.PlainTime]') +}) + +test('a value converts itself, without a connection that asked', has, async (t) => { + const { conn } = await fresh(t) + + // The common case: a result read for its ids and its names, with one + // date in it that is going to be shown. + const date = (await one(conn, new ZuDate(DAYS))).toTemporal() + assert.deepEqual([date.year, date.month, date.day], [2024, 1, 1]) + + assert.equal(tag(new ZuTime(1n, null).toTemporal()), '[object Temporal.PlainTime]') + assert.equal(tag(new ZuTimestamp(INSTANT, null).toTemporal()), '[object Temporal.PlainDateTime]') + assert.equal(tag(new ZuTimestamp(INSTANT, 0).toTemporal()), '[object Temporal.ZonedDateTime]') + assert.equal(new ZuTimestamp(INSTANT, 0).toTemporal().offset, '+00:00') + assert.equal(ZuDuration.ofNanos(90000000005n).toTemporal().seconds, 90) + assert.equal(ZuDuration.ofMonths(14n).toTemporal().months, 14) +}) + +test('a Temporal value binds as a parameter on any connection', has, async (t) => { + // Any connection, including one that never asked for Temporal on the + // way out: recognizing one costs a property read, and a client that + // took a value it would not give back is a client with a rule nobody + // could guess. + const { conn } = await fresh(t) + + const date = await one(conn, Temporal.PlainDate.from('2024-01-01')) + assert.ok(date instanceof ZuDate) + assert.equal(date.days, DAYS) + + const time = await one(conn, Temporal.PlainTime.from('01:00:00.000000001')) + assert.ok(time instanceof ZuTime) + assert.equal(time.nanos, 3600000000001n) + assert.equal(time.offset, null) + + const stamp = await one(conn, Temporal.PlainDateTime.from('2023-11-14T22:13:20.123456789')) + assert.ok(stamp instanceof ZuTimestamp) + assert.equal(stamp.nanos, INSTANT) + assert.equal(stamp.offset, null) + + const zoned = await one(conn, Temporal.ZonedDateTime.from('2023-11-14T14:13:20.123456789-08:00[-08:00]')) + assert.equal(zoned.nanos, INSTANT) + assert.equal(zoned.offset, -480) + + // An instant is UTC, because that is what an instant is. + const instant = await one(conn, Temporal.Instant.from('2023-11-14T22:13:20.123456789Z')) + assert.ok(instant instanceof ZuTimestamp) + assert.equal(instant.nanos, INSTANT) + assert.equal(instant.offset, 0) + + const before = await one(conn, Temporal.PlainDateTime.from('1969-12-31T23:59:59.999999999')) + assert.equal(before.nanos, -1n) +}) + +test('a Temporal duration binds as the kind it is', has, async (t) => { + const { conn } = await fresh(t) + + const months = await one(conn, Temporal.Duration.from({ years: 1, months: 2 })) + assert.equal(months.kind, 'yearMonth') + assert.equal(months.months, 14n) + + const nanos = await one(conn, Temporal.Duration.from({ seconds: 90, nanoseconds: 5 })) + assert.equal(nanos.kind, 'dayTime') + assert.equal(nanos.nanos, 90000000005n) + + // A week is seven days and a day is twenty-four hours, which is what + // Temporal itself assumes for a duration with no date to hang on. + const weeks = await one(conn, Temporal.Duration.from({ weeks: 1, days: 1 })) + assert.equal(weeks.nanos, 691200000000000n) + + const negative = await one(conn, Temporal.Duration.from({ seconds: -90 })) + assert.equal(negative.nanos, -90000000000n) + + // Zero counts neither, and a duration that counts neither is a + // day-time one of no length rather than an error. + const zero = await one(conn, Temporal.Duration.from({ seconds: 0 })) + assert.equal(zero.kind, 'dayTime') + assert.equal(zero.nanos, 0n) + + await assert.rejects( + () => one(conn, Temporal.Duration.from({ months: 1, days: 1 })), + (err) => isZuError(err, 'ZuUsageError') && /both months and days/.test(err.message), + ) +}) + +test('a Temporal value zu has no place for is refused by name', has, async (t) => { + const { conn } = await fresh(t) + + for (const value of [Temporal.PlainYearMonth.from('2024-01'), Temporal.PlainMonthDay.from('01-01')]) { + await assert.rejects( + () => one(conn, value), + (err) => { + assert.ok(isZuError(err, 'ZuUsageError'), `${tag(value)} was accepted`) + assert.match(err.message, /parameter v is a Temporal\./) + return true + }, + ) + } + + // A calendar zu cannot store is refused rather than read as though it + // were ISO, because a date read in the wrong calendar is a different + // day and stores perfectly well. + const hebrew = fromCalendar('hebrew') + if (hebrew !== null) { + await assert.rejects( + () => one(conn, hebrew), + (err) => isZuError(err, 'ZuUsageError') && /hebrew calendar/.test(err.message), + ) + } + + // An instant past what nanoseconds in an INT64 hold, which Temporal + // allows and zu does not. + await assert.rejects( + () => one(conn, Temporal.Instant.fromEpochMilliseconds(8.64e15)), + (err) => isZuError(err, 'ZuUsageError') && /2262-04-11/.test(err.message), + ) +}) + +// A date in a calendar the runtime may not ship, since which calendars +// there are is the runtime's business and what this client does with +// one is not. +function fromCalendar(calendar) { + try { + return Temporal.PlainDate.from('2024-01-01').withCalendar(calendar) + } catch { + return null + } +} + +test('a Temporal value goes out the way it came in', has, async (t) => { + const { conn } = await fresh(t, { temporal: true }) + + for (const value of [ + Temporal.PlainDate.from('2024-01-01'), + Temporal.PlainTime.from('01:00:00.000000001'), + Temporal.PlainDateTime.from('2023-11-14T22:13:20.123456789'), + Temporal.Duration.from({ seconds: 90, nanoseconds: 5 }), + ]) { + const back = await one(conn, value) + assert.equal(tag(back), tag(value)) + assert.equal(back.toString(), value.toString()) + } +}) + +test('a statement and a stream spell temporal values the same way', has, async (t) => { + const { conn } = await fresh(t, { temporal: true }) + await conn.exec("INSERT (d:day {id: 1, on: DATE '2024-01-01'})") + + const rows = await conn.query('MATCH (d:day) RETURN d.on AS on') + assert.equal(tag(rows[0].on), '[object Temporal.PlainDate]') + + const stream = conn.stream('MATCH (d:day) RETURN d.on AS on') + const seen = [] + for await (const row of stream) seen.push(row.on) + assert.equal(seen.length, 1) + assert.equal(tag(seen[0]), '[object Temporal.PlainDate]') + assert.equal(seen[0].day, 1) +}) + +test('the mode is off unless it was asked for, and touches nothing else', has, async (t) => { + for (const options of [undefined, { temporal: false }]) { + const { conn } = await fresh(t, options) + const date = await one(conn, new ZuDate(DAYS)) + assert.ok(date instanceof ZuDate, `${JSON.stringify(options)} gave a Temporal value`) + assert.equal(date.days, DAYS) + } + + const { conn } = await fresh(t, { temporal: true }) + const rows = await conn.query("RETURN 1 AS n, 1.5 AS f, 'ada' AS s, [1] AS xs") + assert.deepEqual({ ...rows[0] }, { n: 1n, f: 1.5, s: 'ada', xs: [1n] }) +}) + +test('a runtime without Temporal says so at the connect', hasNot, async (t) => { + const { dir } = await fresh(t) + + const path = join(dir, 'never.zu1') + await assert.rejects( + () => connect(path, { temporal: true }), + (err) => { + assert.ok(isZuError(err, 'ZuUsageError')) + // The flag, because the runtime that most often lands here is + // Node 24 and turning it on is the whole fix. + assert.match(err.message, /--harmony-temporal/) + return true + }, + ) + // At the connect means before the open, so a program that asked for + // something this runtime cannot do leaves no database behind while + // finding out. + assert.equal(existsSync(path), false) +}) + +test('a runtime without Temporal says so on the value too', hasNot, async (t) => { + const { conn } = await fresh(t) + + const date = await one(conn, new ZuDate(DAYS)) + assert.ok(date instanceof ZuDate) + assert.throws( + () => date.toTemporal(), + (err) => isZuError(err, 'ZuUsageError') && /--harmony-temporal/.test(err.message), + ) +}) diff --git a/test/types/cjs.cts b/test/types/cjs.cts index e094d86..572450f 100644 --- a/test/types/cjs.cts +++ b/test/types/cjs.cts @@ -2,7 +2,7 @@ // resolution through a different condition to a different file, and so // is worth compiling separately rather than assuming. -import { connect, isZuError, type ZuParam, type ZuStream } from 'zudb' +import { connect, isZuError, ZuTimestamp, type ZuParam, type ZuStream } from 'zudb' export async function total(path: string): Promise { // The mode is on the statement here, so the rows it gives back are @@ -31,6 +31,19 @@ export async function insert(path: string, values: Record): Pro } } +export async function moment(path: string, at: bigint): Promise { + // A `Temporal` value binds as a parameter on a connection that never + // asked for temporal mode, so this one is opened without the option + // and the value it binds is made from a class instead. + const conn = await connect(path) + try { + await conn.exec('INSERT (e:event {id: 1, at: $at})', { at: new ZuTimestamp(at) }) + return new ZuTimestamp(at, 120).toTemporal() + } finally { + conn.close() + } +} + export async function ids(path: string): Promise { const conn = await connect(path) const stream: ZuStream<{ id: bigint }> = conn.stream('MATCH (p:person) RETURN p.id AS id') diff --git a/test/types/esm.mts b/test/types/esm.mts index 2a73372..f8a1d93 100644 --- a/test/types/esm.mts +++ b/test/types/esm.mts @@ -9,9 +9,11 @@ import { type ZuBatch, type ZuBigIntMode, type ZuError, + type ZuPlainDate, type ZuRows, type ZuStream, type ZuSummary, + type ZuValue, } from 'zudb' export async function people(path: string, name: string): Promise { @@ -101,3 +103,30 @@ export function retryable(caught: unknown): boolean { export function epoch(): ZuDate { return new ZuDate(0) } + +export async function days(path: string): Promise { + // The temporal mode is on the connection and only there, which is + // what the options type says: a statement naming it does not compile. + await using conn = await connect(path, { temporal: true }) + const rows = await conn.query<{ on: ZuValue }>('MATCH (d:day) RETURN d.on AS on') + return rows.map((row) => row.on) +} + +export function converted(): ZuPlainDate { + // The real `Temporal.PlainDate` on a program whose `lib` declares one + // and `unknown` on a program whose `lib` does not, and it compiles + // either way, which is the whole reason the type is written as a + // question about `globalThis` rather than as an import. + return new ZuDate(0).toTemporal() +} + +export function width(value: ZuValue): number { + // A value out of a result still narrows on a program with no + // `Temporal` types, which is what the empty fallback is for: a member + // that fell back to `unknown` instead would swallow the union and + // this function would stop compiling for everybody. + if (typeof value === 'string') return value.length + if (typeof value === 'bigint') return value.toString().length + if (Array.isArray(value)) return value.length + return 0 +} diff --git a/types/header.d.ts b/types/header.d.ts index a39f21c..40350f0 100644 --- a/types/header.d.ts +++ b/types/header.d.ts @@ -1,6 +1,70 @@ /* auto-generated by NAPI-RS */ /* eslint-disable */ +/** + * `Temporal.PlainDate`, on a program that has it. + * + * Asked of `globalThis` rather than imported, because `Temporal` + * reached Stage 4 in March 2026 and which `lib` declares it is + * different in every version of TypeScript that has shipped since. A + * program compiling against a `lib` that has `Temporal` gets the real + * type here and is checked against it. One compiling against a `lib` + * that does not gets `unknown`, which needs a cast at the call site and + * is the truth: this client cannot promise a type the compiler has + * never heard of, and it should not fail to compile for saying so. + */ +export type ZuPlainDate = typeof globalThis extends { + Temporal: { PlainDate: new (...args: any[]) => infer Value } +} + ? Value + : unknown + +/** `Temporal.PlainTime`, on the terms [[ZuPlainDate]] gives. */ +export type ZuPlainTime = typeof globalThis extends { + Temporal: { PlainTime: new (...args: any[]) => infer Value } +} + ? Value + : unknown + +/** `Temporal.PlainDateTime`, on the terms [[ZuPlainDate]] gives. */ +export type ZuPlainDateTime = typeof globalThis extends { + Temporal: { PlainDateTime: new (...args: any[]) => infer Value } +} + ? Value + : unknown + +/** `Temporal.ZonedDateTime`, on the terms [[ZuPlainDate]] gives. */ +export type ZuZonedDateTime = typeof globalThis extends { + Temporal: { ZonedDateTime: new (...args: any[]) => infer Value } +} + ? Value + : unknown + +/** `Temporal.Duration`, on the terms [[ZuPlainDate]] gives. Named for + * the standard's class rather than for this client's `ZuDuration`, + * which is the other one. */ +export type ZuTemporalDuration = typeof globalThis extends { + Temporal: { Duration: new (...args: any[]) => infer Value } +} + ? Value + : unknown + +/** + * Every `Temporal` value this client understands, and nothing at all on + * a program whose `lib` has no `Temporal`. + * + * Nothing rather than `unknown` there, because this one is a member of + * a union: `unknown` in a union swallows it and would turn every row + * and every parameter into `unknown` for everybody. `never` in a union + * vanishes, so a program without `Temporal` types sees exactly what it + * saw before this existed. + */ +export type ZuTemporalValue = typeof globalThis extends { + Temporal: { Instant: new (...args: any[]) => infer Instant } +} + ? Instant | ZuPlainDate | ZuPlainTime | ZuPlainDateTime | ZuZonedDateTime | ZuTemporalDuration + : never + /** * A value a statement can hold, going out. * @@ -10,6 +74,11 @@ * as a number would be a count you cannot trust. `bigIntMode` changes * that for a statement or for a connection, with the hazard it * documents. + * + * A date, a time, a timestamp and a duration are the four classes by + * default and `Temporal` values on a connection opened with + * `{ temporal: true }`. A time with an offset is the exception in both + * directions: `Temporal` has no type for one, so it stays a `ZuTime`. */ export type ZuValue = | null @@ -24,6 +93,7 @@ export type ZuValue = | ZuTime | ZuTimestamp | ZuDuration + | ZuTemporalValue | ZuValue[] | { [field: string]: ZuValue } @@ -32,7 +102,11 @@ export type ZuValue = * * Wider than what comes out, because a `number` that is whole binds as * INT64 and `undefined` binds as null, which is what makes an optional - * field of a plain object pass straight through. + * field of a plain object pass straight through. A `Temporal` value + * binds as the zu value it is on every connection, whether or not the + * 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. */ export type ZuParam = | null @@ -45,6 +119,7 @@ export type ZuParam = | ZuTime | ZuTimestamp | ZuDuration + | ZuTemporalValue | ZuParam[] | { [field: string]: ZuParam }