Skip to content

the JavaScript client: connect, run a statement, read the rows - #1

Merged
tamnd merged 1 commit into
mainfrom
ts-client
Aug 18, 2026
Merged

tamnd merged 1 commit into
mainfrom
ts-client

Conversation

@tamnd

@tamnd tamnd commented Aug 18, 2026

Copy link
Copy Markdown
Owner

The first working piece of the JS client. connect, query, exec, close, await using, named parameters both ways, every value the engine has, and the error surface.

await using conn = await connect("social.zu1");
await conn.exec(`INSERT (p:Person {id: 1, name: 'ada'})`);
const rows = await conn.query<{ id: bigint }>(`MATCH (p:Person) WHERE p.name = $name RETURN p.id AS id`, { name: "ada" });

What it does

The binding is napi-rs and not the C ABI, which is ADR 0002. The ABI is the specification a client is graded against, and a client that links the engine's crates directly implements its semantics without paying for a second copy of every value on the way out.

Every engine call is a task on libuv's threadpool and every method hands back a promise before the statement has started, so nothing here can block the event loop. A connection is not thread safe in the engine, so the one held here sits behind a mutex. That is not a way of making it concurrent, since a statement holds the lock for as long as it runs and two statements that should overlap want two connections. It is there so that a program which shares one by accident waits rather than corrupts.

A result is an array of row objects, with the projection, the GQLSTATUS and the notices beside the elements as properties that are not enumerable. Iterating it is for...of and nothing else, and the same value still spreads, stringifies and deep-equals the plain array it is.

INT64 is bigint, always, which is ADR 0003. Going the other way a whole number binds as INT64 and a fractional one as FLOAT, because { id: 1 } is what a caller writes and refusing it would be pedantry.

A failure is an ordinary Error carrying every field of the condition, so nothing has to be parsed back out of a 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, retryable decides whether a retry loop goes round again. A mistake this client caught before the engine saw it is named ZuUsageError and carries no code, so a caller mapping codes to branches can tell a missing one from an unknown one. Both kinds arrive as a rejection of the promise the call already returned, including a closed connection and a parameter of a type nothing can bind, so one await catches everything one statement can do.

await using works by putting dispose on each connection under Symbol.asyncDispose, which is the one thing about the class that the attribute declaring the rest of it cannot spell, since a method's name there is a string and this key is a symbol.

Tests and numbers

Thirty four tests over connecting, statements, values both ways and the error surface, all green locally on Node 24. The CI workflow runs fmt, clippy and the suite on Node 24 and 26 across Linux, macOS and Windows.

npm run bench measures what this package adds to the engine rather than the engine itself: the same scan with the rows dropped is the floor, and the difference is the row object and one JavaScript value per column. On this machine, twenty thousand rows of two columns scan in about 9 ms against a floor of about 1.8 ms, so the boundary costs roughly 400 ns per row of two columns and about half that per column added.

What is not here

Prebuilt binaries under optionalDependencies, AsyncIterable and Web Streams, AbortSignal wired to the engine's interrupt, bigIntMode, toTemporal() and { temporal: true }, dual ESM and CJS, and Bun and Deno in CI. They are the remaining lines of DX3 and each one lands with its own tests.

Two things worth knowing while reading the tests. CREATE NODE TABLE is not implemented in the engine yet, so a table is declared by its first insert and that one is written with literals because the engine works out what each column holds from the values it was given. And a read-only connection does not see writes that live only in the .wal, so no test depends on that.

Milestone: tamnd/zu#169.

The binding is napi-rs and not the C ABI, which is ADR 0002: the ABI is
the specification a client is graded against, and a client that links
the engine's crates directly implements its semantics without paying
for a second copy of every value on the way out.

Every engine call is a task on libuv's threadpool and every method
hands back a promise before the statement has started, so nothing here
can block the event loop. A connection is not thread safe in the
engine, so the one held here sits behind a mutex: not to make it
concurrent, since a statement holds the lock for as long as it runs,
but so that a program which shares one by accident waits rather than
corrupts.

A result is an array of row objects with the projection, the GQLSTATUS
and the notices beside the elements as properties that are not
enumerable, so iterating it is for...of and nothing else and the same
value still spreads, stringifies and compares as the plain array it is.

INT64 is bigint, always, which is ADR 0003. Going the other way a whole
number binds as INT64 and a fractional one as FLOAT, because { id: 1 }
is what a caller writes.

A failure is an ordinary Error carrying every field of the condition,
so nothing has to be parsed back out of a message: code is the
GQLSTATUS, condition is the standard's own words, line and column and
excerpt underline the token, retryable decides whether a retry loop
goes round again. A mistake this client caught before the engine saw it
is named ZuUsageError and carries no code, so a caller mapping codes to
branches can tell a missing one from an unknown one. Both kinds arrive
as a rejection of the promise the call already returned, including a
closed connection and a parameter of a type nothing can bind, so one
await catches everything one statement can do.

Thirty four tests over connecting, statements, values both ways and the
error surface, and a benchmark that measures what this package adds to
the engine by comparing a scan against the same scan with the rows
dropped.
@tamnd
tamnd merged commit 95fca18 into main Aug 18, 2026
8 checks passed
@tamnd
tamnd deleted the ts-client branch August 18, 2026 11:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant