Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
42 changes: 40 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
41 changes: 39 additions & 2 deletions bench/query.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -163,5 +199,6 @@ for (const { name, per, run } of cases) {
)
}

temporal?.close()
conn.close()
await rm(dir, { recursive: true, force: true })
138 changes: 137 additions & 1 deletion binding.d.cts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand All @@ -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
Expand All @@ -24,6 +93,7 @@ export type ZuValue =
| ZuTime
| ZuTimestamp
| ZuDuration
| ZuTemporalValue
| ZuValue[]
| { [field: string]: ZuValue }

Expand All @@ -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
Expand All @@ -45,6 +119,7 @@ export type ZuParam =
| ZuTime
| ZuTimestamp
| ZuDuration
| ZuTemporalValue
| ZuParam[]
| { [field: string]: ZuParam }

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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. */
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading