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

## What works today

`connect`, `query`, `exec`, `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. The full error surface above.
`connect`, `query`, `exec`, `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.

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.

## Stopping a statement

Every statement takes a third argument, and what is in it today is a signal:

```ts
const rows = await conn.query(statement, params, { signal: AbortSignal.timeout(50) });
```

It is the signal JavaScript already has, so a timeout written like the one above, the signal a framework hands a request handler, and an `AbortSignal.any([...])` composed out of both all work here without anything being adapted. When it fires, the engine's interrupt is raised, the executor notices it at a boundary it was already stopping at, and the statement ends inside a vector of rows rather than at the end of the scan. The connection is left exactly as it was, so the statement after a stopped one runs normally.

What the promise rejects with is the signal's own reason, which is what `fetch` does: `AbortSignal.timeout(50)` rejects with the runtime's `TimeoutError`, `controller.abort(new RequestGone())` rejects with the `RequestGone` you made, and a bare `controller.abort()` rejects with the runtime's `AbortError`. A signal that has already fired stops the statement before the engine sees it at all. A signal that never fires costs one listener, taken off again when the statement ends, whether it answered, failed or was stopped.

## Installing, once there is something to install

`npm i zudb`, and that is the whole of it. The install downloads one file, runs nothing, and needs no compiler: the root package carries the loader and no binary, each platform has its own package holding exactly one addon, and npm picks the one for the machine out of `optionalDependencies` by its `os`, `cpu` and `libc`. There is no `postinstall`, no `node-gyp`, no `node-pre-gyp` and no fetch from anywhere but the registry, which is what makes the package installable behind a proxy, inside a locked-down CI image, and on a machine with no toolchain on it.
Expand All @@ -60,7 +72,7 @@ Anything outside that table has no binary and no source build to fall back on, s

## Still to come

`AsyncIterable` and Web Streams over a result, and `AbortSignal` wired to the engine's interrupt. `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. Dual ESM and CJS, with types first in every export condition. Bun and Deno in CI, and the WASM build for the browser.
`AsyncIterable` and Web Streams over a result. `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. Dual ESM and CJS, with types first in every export condition. Bun and Deno in CI, and the WASM build for the browser.

## Runtimes

Expand Down
13 changes: 13 additions & 0 deletions bench/query.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ async function time(run) {
return best
}

// A signal nobody ever fires, which is the shape almost every signal
// passed to a database has.
const idle = new AbortController()

const cases = [
{
name: 'scan, two columns',
Expand All @@ -84,6 +88,15 @@ const cases = [
per: 'statement',
run: () => conn.query('MATCH (p:person) RETURN count(*) AS n'),
},
{
// What watching a signal costs a statement nobody stops, which is
// every statement in a server that passes the request's signal down.
// A listener added and taken off again, once per statement and not
// once per row, so the number to read this against is the one above.
name: 'aggregate, one signal in',
per: 'statement',
run: () => conn.query('MATCH (p:person) RETURN count(*) AS n', null, { signal: idle.signal }),
},
{
name: 'aggregate, one parameter in',
per: 'statement',
Expand Down
27 changes: 25 additions & 2 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,29 @@ export interface ZuRows<Row = Record<string, ZuValue>> extends Array<Row> {
readonly notices: ZuNotice[]
}

/**
* What a statement takes beside its parameters.
*
* An object rather than a bare signal, because the options that follow
* it belong in the same place and a third argument that changes meaning
* is one nobody can read at a call site.
*/
export interface ZuStatementOptions {
/**
* Stops the statement when it fires, through the same interrupt a
* shell answers `Ctrl-C` with: the executor notices at the boundary it
* was already stopping at, the statement ends, and the connection is
* exactly as it was. The promise rejects with whatever the signal
* gives as its reason, which is what `fetch` does, so
* `AbortSignal.timeout(50)` rejects with a `TimeoutError` and
* `controller.abort(new MyError())` rejects with `MyError`.
*
* A signal that has already fired stops the statement before the
* engine sees it at all.
*/
readonly signal?: AbortSignal
}

/**
* What a failed call throws.
*
Expand Down Expand Up @@ -147,15 +170,15 @@ export declare class Connection {
* statement does not use is an error from the engine rather than a
* value quietly ignored.
*/
query<Row = Record<string, ZuValue>>(statement: string, params?: Record<string, ZuParam> | null): Promise<ZuRows<Row>>
query<Row = Record<string, ZuValue>>(statement: string, params?: Record<string, ZuParam> | null, options?: ZuStatementOptions | null): Promise<ZuRows<Row>>
/**
* Runs one statement for its effect and gives back nothing.
*
* The same call as [`Connection::query`] with the rows dropped,
* which is what a schema statement or a write wants: a result nobody
* reads still costs a row object per row on the way out.
*/
exec(statement: string, params?: Record<string, ZuParam> | null): Promise<void>
exec(statement: string, params?: Record<string, ZuParam> | null, options?: ZuStatementOptions | null): Promise<void>
/**
* Closes the connection and releases the database.
*
Expand Down
182 changes: 182 additions & 0 deletions src/cancel.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
//! An `AbortSignal`, wired to the statement it is meant to stop.
//!
//! Cancellation in JavaScript has one shape and every library uses it,
//! so a statement takes an `AbortSignal` rather than a handle of its
//! own: the timeout a caller already has from `AbortSignal.timeout`, the
//! signal a web framework hands a request handler, and the one a caller
//! composes with `AbortSignal.any` all work here without anything being
//! adapted. What arrives on the other side is the engine's interrupt,
//! which is one word the executor reads at a boundary it was already
//! stopping at, so a statement that is asked to stop does so within a
//! vector of rows and leaves the connection exactly as it was.
//!
//! Two things make this harder than adding a listener. The interrupt
//! belongs to the connection rather than to the statement, so a stop
//! raised a moment too late would end the next statement instead of the
//! one it was meant for. And a signal can fire before the statement
//! reaches the thread it runs on, which is a stop nobody is there to
//! hear. Both are answered the same way: the listener and the statement
//! share two words and set them in the opposite order, so whichever of
//! them is second sees what the other did.

use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};

use napi::bindgen_prelude::*;
use napi::{Env, ValueType};
use zudb::Interrupt;

/// The two words a listener and a statement share.
///
/// `asked` is set by the listener and read by the statement, `running`
/// the other way about. Both are sequentially consistent, which is what
/// makes the pair work: the listener sets `asked` before it reads
/// `running`, the statement sets `running` before it reads `asked`, and
/// an ordering where neither sees the other is one this ordering
/// forbids.
#[derive(Debug)]
struct Shared {
asked: AtomicBool,
running: AtomicBool,
interrupt: Interrupt,
}

impl Shared {
/// The listener's half: say that somebody asked, then stop the
/// statement if one is running.
fn ask(&self) {
self.asked.store(true, Ordering::SeqCst);
if self.running.load(Ordering::SeqCst) {
self.interrupt.stop();
}
}
}

/// A signal watching one statement.
///
/// It is made on the thread that owns the runtime, because that is the
/// only thread that may add a listener, and it is taken apart there too.
pub struct Watch {
shared: Arc<Shared>,
/// Kept so the reason can be read when the statement gives up, and
/// so the listener can be taken off again.
signal: ObjectRef,
listener: FunctionRef<(), ()>,
}

impl Watch {
/// Watches `signal`, and stops `interrupt` when it fires.
///
/// A signal that has already fired is still watched rather than
/// refused here, so that one place decides what an aborted statement
/// does and the answer does not depend on how early the caller was.
pub fn new(env: &Env, signal: Object<'_>, interrupt: Interrupt) -> Result<Watch> {
let shared = Arc::new(Shared {
asked: AtomicBool::new(signal.get_named_property::<bool>("aborted")?),
running: AtomicBool::new(false),
interrupt,
});

let held = Arc::clone(&shared);
let listener: Function<'_, (), ()> =
env.create_function_from_closure("abort", move |_| {
held.ask();
Ok(())
})?;

let mut once = Object::new(env)?;
once.set("once", true)?;
// `FnArgs` rather than the tuple on its own, because a bare tuple
// is one argument to napi and a JavaScript function called with
// one argument that happens to be three values is a function
// called wrongly. The failure that teaches this is
// `addEventListener` complaining that its arguments were not
// specified while three of them sit in the call.
let add: Function<'_, FnArgs<(Unknown<'_>, Unknown<'_>, Unknown<'_>)>, Unknown<'_>> =
signal.get_named_property("addEventListener")?;
add.apply(
signal,
(
env.create_string("abort")?.into_unknown(env)?,
listener.into_unknown(env)?,
once.into_unknown(env)?,
)
.into(),
)?;

Ok(Watch {
shared,
signal: signal.create_ref()?,
listener: listener.create_ref()?,
})
}

/// The statement is about to run. `false` means it must not: the
/// signal fired first.
pub fn enter(&self) -> bool {
self.shared.running.store(true, Ordering::SeqCst);
!self.shared.asked.load(Ordering::SeqCst)
}

/// The statement is done with the connection.
///
/// The flag goes down first, so a signal firing now raises nothing,
/// and then the interrupt is put back down, so a stop that landed in
/// the moment between the statement finishing and this call cannot
/// end whatever runs next on the same connection.
pub fn leave(&self) {
self.shared.running.store(false, Ordering::SeqCst);
self.shared.interrupt.clear();
}

/// Whether the signal fired at all, which is what tells an interrupt
/// the caller asked for apart from one they did not.
pub fn asked(&self) -> bool {
self.shared.asked.load(Ordering::SeqCst)
}

/// What the signal gives as its reason, if it gives one.
///
/// Every runtime sets a reason on a signal that has fired, an
/// `AbortError` when the caller named nothing of their own, so this
/// is almost always something. It is an option rather than a promise
/// of one because a rejection with `undefined` in it is worse than
/// the plain sentence this client would write instead.
pub fn reason<'env>(&self, env: &'env Env) -> Option<Unknown<'env>> {
let signal = self.signal.get_value(env).ok()?;
let reason: Unknown<'env> = signal.get_named_property("reason").ok()?;
match reason.get_type() {
Ok(ValueType::Undefined) | Ok(ValueType::Null) | Err(_) => None,
Ok(_) => Some(reason),
}
}

/// Takes the listener off the signal and releases both references.
///
/// A signal outlives the statement it stopped, often by the length
/// of a whole request, and a listener left on one is a statement's
/// worth of memory that never goes away. This is why the statement
/// keeps the function it added rather than adding a fresh closure
/// and hoping `once` covers it: `once` only fires for a signal that
/// aborts, and most of them never do.
pub fn release(self, env: &Env) -> Result<()> {
let signal = self.signal.get_value(env)?;
let listener = self.listener.borrow_back(env)?;
let remove: Function<'_, FnArgs<(Unknown<'_>, Unknown<'_>)>, Unknown<'_>> =
signal.get_named_property("removeEventListener")?;
remove.apply(
signal,
(
env.create_string("abort")?.into_unknown(env)?,
listener.into_unknown(env)?,
)
.into(),
)?;

// The function's reference releases itself when it drops, on
// this thread or through the environment's own collector if it
// ever drops on another. The object's does not, and says so on
// stderr when it is forgotten, so it is released here.
self.signal.unref(env)
}
}
Loading