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
251 changes: 251 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
name: release

# One binary per platform, one npm package per binary, and a root
# package that carries no binary at all. That is what makes an install
# a download of one file for the machine doing the installing, with no
# compiler, no node-gyp and no script running on the user's machine.
#
# The matrix is the tier 1 rows of platforms.toml in tamnd/zu, plus
# Windows on arm64, which is tier 2 there and cheap here because the
# runner exists. Anything outside it is a source build, which is what
# `npm i` falls back to failing at today and what the WASM build will
# answer for later.
#
# It runs on the pull request that changes how a binary is built,
# because the alternative is finding out on the tag, which is the one
# moment a release cannot be rebuilt quietly.

on:
# What the release conductor in tamnd/zu calls, with the version the
# whole train is releasing.
workflow_dispatch:
inputs:
version:
description: The version being released
required: false
default: 0.0.0
type: string
push:
tags: ["v*"]
pull_request:
paths:
- .github/workflows/release.yml
- package.json
- npm/**
- Cargo.toml
- Cargo.lock
- rust-toolchain.toml

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

env:
CARGO_TERM_COLOR: always
# The Node the two Linux images do not carry. Pinned, like every other
# toolchain here, because a build whose tools float is a build nobody
# can reproduce on the day it breaks.
NODE_VERSION: v24.19.0

jobs:
binary:
name: ${{ matrix.target }}
strategy:
# Eight platforms, and the interesting run is the one that says
# which of them broke rather than the first one to.
fail-fast: false
matrix:
include:
# The glibc rows build inside manylinux_2_28, which is the
# floor platforms.toml sets and which covers RHEL 8 and
# everything newer. A binary linked against the runner's own
# glibc is the failure that gets reported as "works on my
# machine": it loads where it was built and dies on the user's.
- target: x86_64-unknown-linux-gnu
runner: ubuntu-latest
container: quay.io/pypa/manylinux_2_28_x86_64:2026.08.15-1
node: linux-x64
- target: aarch64-unknown-linux-gnu
runner: ubuntu-24.04-arm
container: quay.io/pypa/manylinux_2_28_aarch64:2026.08.15-1
node: linux-arm64
# The musl rows build inside Alpine through docker rather than
# as a job container. GitHub runs the JavaScript half of an
# action inside the job's container and has no Alpine build of
# it for arm64, so a job container of Alpine is a checkout that
# fails on the arm runner and passes on the other.
- target: x86_64-unknown-linux-musl
runner: ubuntu-latest
alpine: alpine:3.24.1
- target: aarch64-unknown-linux-musl
runner: ubuntu-24.04-arm
alpine: alpine:3.24.1
- target: aarch64-apple-darwin
runner: macos-latest
# Cross compiled from the arm runner, which on macOS is one
# flag: the linker and the C compiler both take an -arch and
# Apple ships both halves in one toolchain.
- target: x86_64-apple-darwin
runner: macos-latest
- target: x86_64-pc-windows-msvc
runner: windows-latest
- target: aarch64-pc-windows-msvc
runner: windows-11-arm
runs-on: ${{ matrix.runner }}
container: ${{ matrix.container }}
steps:
- uses: actions/checkout@v7

# The manylinux image carries a compiler for wheels and nothing
# for this, so the two things this build needs go in by hand. The
# tarball rather than a package, because the image is RHEL 8 and
# its Node is older than this package's floor.
- name: Node and rustup, inside the image
if: matrix.container != ''
run: |
set -eu
curl -fsSL "https://nodejs.org/dist/$NODE_VERSION/node-$NODE_VERSION-${{ matrix.node }}.tar.xz" \
| tar -xJ -C /usr/local --strip-components=1
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"

- uses: actions/setup-node@v7
if: matrix.container == '' && matrix.alpine == ''
with:
node-version: 24

# rust-toolchain.toml names the version and its components, and
# the rustup every hosted image ships installs both on the first
# cargo command. Only the target has to be asked for, and only
# where it is not the machine's own.
- name: The target, where it is not this machine's
if: matrix.alpine == '' && matrix.target == 'x86_64-apple-darwin'
run: rustup target add ${{ matrix.target }}

- uses: Swatinem/rust-cache@v2
if: matrix.alpine == ''
with:
key: ${{ matrix.target }}

- name: Build
if: matrix.alpine == ''
shell: bash
run: |
set -eu
npm ci
npx napi build --platform --release --target ${{ matrix.target }}

# Alpine builds the row's own architecture natively, so there is
# no target flag: inside the image the default target is the musl
# one. The crt-static override is what makes a shared object
# possible at all on musl, whose default is to link the C runtime
# statically into everything.
#
# The linker is named per target rather than in RUSTFLAGS, because
# napi passes --target and cargo stops applying RUSTFLAGS to host
# artifacts as soon as it sees one. The host artifacts here are the
# build scripts of proc macro crates, which is exactly where the
# arm64 row was failing: Rust's aarch64 musl target asks for
# aarch64-linux-musl-gcc, which is what a cross toolchain calls its
# compiler and not what a machine compiling for itself has. On
# Alpine the compiler is gcc, for both rows.
- name: Build, inside Alpine
if: matrix.alpine != ''
run: |
docker run --rm -v "$PWD":/work -w /work \
-e RUSTFLAGS="-C target-feature=-crt-static" \
-e CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=gcc \
-e CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=gcc \
${{ matrix.alpine }} sh -c '
set -eu
apk add --no-cache nodejs npm rustup build-base
rustup-init -y --no-modify-path --profile minimal
export PATH="$HOME/.cargo/bin:$PATH"
npm ci
npx napi build --platform --release
'

# One file, named after the platform it runs on, which is what the
# loader looks for and what the platform package publishes.
- name: The binary is the one this row was for
run: node tools/binary.mjs ${{ matrix.target }}

- uses: actions/upload-artifact@v4
with:
name: binary-${{ matrix.target }}
path: ./*.node
if-no-files-found: error

# The eight binaries laid into the eight packages, checked as a set.
# A missing one here is a platform that would have installed and then
# failed at the require, which is the failure this job exists to turn
# into a red build instead.
packages:
needs: binary
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- run: npm ci
- uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- run: npx napi artifacts --output-dir ./artifacts
# Which lays each binary into its package and writes the eight
# optional dependencies into the root manifest at the version being
# released. Nothing is published here: the tag decides that, and
# this job runs on pull requests too.
- run: npx napi pre-publish --no-gh-release --skip-optional-publish
- name: Every package holds the binary it names
run: node tools/packages.mjs
- name: An install of what would be published runs a statement
run: node tools/install.mjs
- uses: actions/upload-artifact@v4
with:
name: packages
path: npm/
if-no-files-found: error

# Publishing is the one step that cannot be taken back, so it happens
# on a tag and nowhere else. The platform packages go first: the root
# package is what a user installs, and it is worthless until every
# optional dependency it names exists.
publish:
needs: packages
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
registry-url: https://registry.npmjs.org
- run: npm ci
- uses: actions/download-artifact@v4
with:
name: packages
path: npm/
# Again, on this checkout, because the manifest a publish reads is
# the one in the working directory and the job before this one
# wrote its copy somewhere else.
- run: npx napi pre-publish --no-gh-release --skip-optional-publish
- run: node tools/packages.mjs
- name: Publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -eu
for each in npm/*/; do
npm publish "$each" --access public --provenance
done
npm publish --access public --provenance
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,30 @@ The rows are an array, so iterating them is `for (const row of rows)` and nothin

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

Build it with `npm run build`, and run the suite with `npm test`. There are no published binaries yet, so `npm i zudb` is not a thing you can type at anybody's terminal.
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.

## 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.

| Machine | Package | Built on |
|---|---|---|
| Linux x64, glibc | `zudb-linux-x64-gnu` | manylinux_2_28, so glibc 2.28 and newer, which is RHEL 8 and newer |
| Linux arm64, glibc | `zudb-linux-arm64-gnu` | the same image, the same floor |
| Linux x64, musl | `zudb-linux-x64-musl` | Alpine 3.24 |
| Linux arm64, musl | `zudb-linux-arm64-musl` | Alpine 3.24 |
| macOS arm64 | `zudb-darwin-arm64` | the hosted arm64 runner |
| macOS x64 | `zudb-darwin-x64` | cross compiled from that same runner |
| Windows x64 | `zudb-win32-x64-msvc` | the hosted x64 runner |
| Windows arm64 | `zudb-win32-arm64-msvc` | the hosted arm64 runner |

Anything outside that table has no binary and no source build to fall back on, so the install resolves nothing and the first `require` says so. The browser and the platforms nobody builds for are what the WASM target answers, later.

`npm run bench` measures what this package adds to the engine, which is a row object and one JavaScript value per column: the same scan with the rows dropped is the floor, and the difference between the two is what the boundary costs. Run it against a release build, since a debug build of the engine moves the floor by an order of magnitude and not the rest of it.

## Still to come

Prebuilt binaries under `optionalDependencies`, with no postinstall script and no `node-gyp`. `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, 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.

## Runtimes

Expand Down
3 changes: 3 additions & 0 deletions npm/darwin-arm64/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# `zudb-darwin-arm64`

This is the **aarch64-apple-darwin** binary for `zudb`
31 changes: 31 additions & 0 deletions npm/darwin-arm64/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "zudb-darwin-arm64",
"version": "0.0.1",
"cpu": [
"arm64"
],
"main": "zudb.darwin-arm64.node",
"files": [
"zudb.darwin-arm64.node"
],
"description": "The JavaScript client for zu, an embedded property-graph database",
"keywords": [
"graph",
"database",
"embedded",
"gql",
"cypher"
],
"homepage": "https://zu.dev",
"license": "Apache-2.0",
"engines": {
"node": ">= 24"
},
"repository": {
"type": "git",
"url": "git+https://github.com/tamnd/zu-node.git"
},
"os": [
"darwin"
]
}
3 changes: 3 additions & 0 deletions npm/darwin-x64/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# `zudb-darwin-x64`

This is the **x86_64-apple-darwin** binary for `zudb`
31 changes: 31 additions & 0 deletions npm/darwin-x64/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "zudb-darwin-x64",
"version": "0.0.1",
"cpu": [
"x64"
],
"main": "zudb.darwin-x64.node",
"files": [
"zudb.darwin-x64.node"
],
"description": "The JavaScript client for zu, an embedded property-graph database",
"keywords": [
"graph",
"database",
"embedded",
"gql",
"cypher"
],
"homepage": "https://zu.dev",
"license": "Apache-2.0",
"engines": {
"node": ">= 24"
},
"repository": {
"type": "git",
"url": "git+https://github.com/tamnd/zu-node.git"
},
"os": [
"darwin"
]
}
3 changes: 3 additions & 0 deletions npm/linux-arm64-gnu/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# `zudb-linux-arm64-gnu`

This is the **aarch64-unknown-linux-gnu** binary for `zudb`
34 changes: 34 additions & 0 deletions npm/linux-arm64-gnu/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "zudb-linux-arm64-gnu",
"version": "0.0.1",
"cpu": [
"arm64"
],
"main": "zudb.linux-arm64-gnu.node",
"files": [
"zudb.linux-arm64-gnu.node"
],
"description": "The JavaScript client for zu, an embedded property-graph database",
"keywords": [
"graph",
"database",
"embedded",
"gql",
"cypher"
],
"homepage": "https://zu.dev",
"license": "Apache-2.0",
"engines": {
"node": ">= 24"
},
"repository": {
"type": "git",
"url": "git+https://github.com/tamnd/zu-node.git"
},
"os": [
"linux"
],
"libc": [
"glibc"
]
}
3 changes: 3 additions & 0 deletions npm/linux-arm64-musl/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# `zudb-linux-arm64-musl`

This is the **aarch64-unknown-linux-musl** binary for `zudb`
Loading
Loading