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
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ The rows are an array, so iterating them is `for (const row of rows)` and nothin

## Decisions worth knowing before you start

- **INT64 is `bigint`.** Always, by default. A JavaScript number stops being exact at 2^53 and zu's integers go to 2^63, so a count that came back as a number would be a count you cannot trust. `{ bigIntMode: "number" }` is planned, will be documented with its precision hazard, and will never be the default.
- **INT64 is `bigint`.** By default, everywhere. A JavaScript number stops being exact at 2^53 and zu's integers go to 2^63, so a count that came back as a number would be a count you cannot trust. `{ bigIntMode: "number" }` asks for the other spelling, and the section below is what it costs.
- **Nothing blocks the event loop.** Every native call runs on libuv's threadpool and hands back a promise before the statement has started. There is no synchronous variant, and the ones that arrive later will say in their own documentation that they belong in scripts, not servers.
- **`await using` is the intended scoping.** A connection is `Symbol.asyncDispose`, and `close()` stays public for callers who cannot use the syntax.
- **A failure is an ordinary `Error`.** Every `catch`, logger and rejection handler already knows what to do with one. What makes it a zu error is the fields, and none of them has to be parsed back out of the message: `code` is the GQLSTATUS and picks the branch, `condition` is the standard's own words for it, `line` and `column` and `excerpt` underline the token, and `retryable` decides whether a retry loop goes round again. A mistake this client caught before the engine saw it carries no `code` and is named `ZuUsageError`, so a caller mapping codes to branches can tell a missing code from one it does not recognize. `isZuError(caught)` is the exported guard for the `catch` clause, where the value is `unknown` and could be anything at all, and in TypeScript it narrows to the full shape.
- **A refusal is a rejection.** A closed connection and a parameter of a type nothing can bind are refused inside the promise rather than thrown out of the call, so one `await` catches everything one statement can do.

## 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. 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`. 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 @@ -74,6 +74,26 @@ Between the statement and the loop sit two batches, which is the whole of the bu

A statement that has to see every row before it can give one, which is `ORDER BY`, `DISTINCT` and the aggregates, runs whole and is handed over in batches afterwards. The loop is the same either way and `summary.streamed` is what tells them apart.

## Asking for numbers instead of bigints

`bigIntMode` says how INT64 is spelled on the way out. It goes on one statement, or on a connection for all of them, and a statement on a connection that named one may still name the other:

```ts
const conn = await connect("social.zu1", { bigIntMode: "number" });
const rows = await conn.query<{ id: number }>(`MATCH (p:Person) RETURN p.id AS id`);
JSON.stringify(rows); // works, which it does not with bigints in it

const exact = await conn.query(`MATCH (p:Person) RETURN count(*) AS n`, null, {
bigIntMode: "bigint",
});
```

Two things are usually behind the ask. `JSON.stringify` throws on a `bigint`, so a row holding one cannot be handed straight to a response, and arithmetic on a `bigint` will not mix with a `number`, so every `+` in the reporting code needs a conversion. Numbers are also slightly cheaper to make: on 50k rows here, one INT64 column costs about 190ns a row as numbers against about 220ns as bigints.

What is traded for that is worth stating plainly, because it is the reason this is never the default. Which integers a database holds is a property of the data and not of the program, so a query that returned numbers for every row of a test database is a query that can meet a larger one in production. This client refuses that row rather than rounding it: an integer past 2^53 raises a `ZuUsageError` naming the column and the value, so the failure is loud and local instead of an answer that is quietly off by one. It is still a failure that arrives at read time, on a machine that is not yours.

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.

## Importing it, either way

```ts
Expand Down Expand Up @@ -106,7 +126,7 @@ Anything outside that table has no binary and no source build to fall back on, s

## Still to come

`bigIntMode`. `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.
`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.

## Runtimes

Expand Down
10 changes: 10 additions & 0 deletions bench/query.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ const cases = [
per: 'row',
run: () => conn.query('MATCH (p:person) RETURN p.id AS id'),
},
{
// The same column spelled as a number instead of a `bigint`, which
// is the cost the other mode is usually asked for: a `bigint` is an
// allocation and a double is not, so the difference between these
// two is what a program buys with the hazard it takes on.
name: 'scan, one INT64 as a number',
per: 'row',
run: () =>
conn.query('MATCH (p:person) RETURN p.id AS id', null, { bigIntMode: 'number' }),
},
{
name: 'scan, whole nodes',
per: 'row',
Expand Down
44 changes: 43 additions & 1 deletion binding.d.cts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
* INT64 is `bigint` and FLOAT is `number`, which is the one rule worth
* learning before anything else here: a JavaScript number stops being
* exact at 2^53 and zu's integers go to 2^63, so a count that came back
* as a number would be a count you cannot trust.
* 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.
*/
export type ZuValue =
| null
Expand Down Expand Up @@ -148,6 +150,34 @@ export interface ZuStreamOptions extends ZuStatementOptions {
readonly batchRows?: number
}

/**
* How INT64 is spelled on the way out.
*
* `bigint` is the default and is the only one of the two that is always
* right, because zu's integers go to 2^63 and a JavaScript number stops
* telling one integer from the next at 2^53.
*
* `number` is for the program that has already decided its integers are
* small: ids that count in millions, a `count(*)` over a table that
* will never be one, a row about to be handed to `JSON.stringify`,
* which cannot serialize a `bigint` at all. It is worth knowing exactly
* what is being traded for that. Which integers a database holds is a
* property of the data and not of the program, so a query that returned
* numbers for every row of a test database is a query that can meet a
* larger one in production. This client refuses that row rather than
* rounding it, with a `ZuUsageError` naming the column and the value,
* so the failure is loud and local rather than an answer that is quietly
* off by one. It is still a failure that arrives at read time, on a
* machine that is not yours, which is why the default is the other one.
*
* 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 of the temporal classes stay `bigint` in both modes, because
* they are properties of classes the addon registers once and not
* values a statement can respell.
*/
export type ZuBigIntMode = 'bigint' | 'number'

/**
* What a statement takes beside its parameters.
*
Expand All @@ -156,6 +186,12 @@ export interface ZuStreamOptions extends ZuStatementOptions {
* is one nobody can read at a call site.
*/
export interface ZuStatementOptions {
/**
* How INT64 comes back from this statement. `bigint` unless the
* connection was opened with the other mode, and either way a
* statement may name the one it wants.
*/
readonly bigIntMode?: ZuBigIntMode
/**
* Stops the statement when it fires, through the same interrupt a
* shell answers `Ctrl-C` with: the executor notices at the boundary it
Expand Down Expand Up @@ -485,6 +521,12 @@ export interface ConnectOptions {
memoryLimit?: bigint
/** How many threads the executor may use. */
threads?: number
/**
* How INT64 comes back, for every statement on this connection.
* `bigint` unless it is said otherwise here, and a statement may
* say otherwise again for itself.
*/
bigIntMode?: ZuBigIntMode
}

/** The version of the client. */
Expand Down
Loading