From 73adcb227735163f6b48d6b8b0706003b4a15d2c Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:00:04 +0700 Subject: [PATCH 1/3] one binary per platform, one package per binary, and nothing running at install time An install of this package is a download of one file for the machine doing the installing. The root package carries the loader and no binary, each of the eight platforms has its own package holding exactly one addon, and npm picks the one that fits out of optionalDependencies by os, cpu and libc. No postinstall, no node-gyp, no compiler and no fetch from anywhere but the registry. The platform list 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. It lives once, in tools/platforms.mjs, and the release workflow builds it, the test suite holds package.json and the npm directories to it, and tools/packages.mjs checks that every row ended up with a binary in it before anything is published. The glibc rows build inside manylinux_2_28 rather than on the runner, because a binary linked against the runner's own glibc loads where it was built and dies on the user's. The musl rows build inside Alpine through docker rather than as a job container, because GitHub runs the JavaScript half of an action inside the job's container and has no Alpine build of it for arm64. The optional dependencies are not checked in. A package that has not been published yet resolves to nothing, so npm install records the range and no lock entry, and every npm ci after it fails on all eight. napi pre-publish writes the block from napi.targets at the version being released, and there is a test that keeps it out of the manifest for the reason it has to stay out. tools/install.mjs is the check that reads nothing: it packs the two tarballs npm would fetch, installs them into an empty project outside the checkout, and runs a statement through what came out. --- .github/workflows/release.yml | 240 ++++++++++++++++++++++++++++++ README.md | 21 ++- npm/darwin-arm64/README.md | 3 + npm/darwin-arm64/package.json | 31 ++++ npm/darwin-x64/README.md | 3 + npm/darwin-x64/package.json | 31 ++++ npm/linux-arm64-gnu/README.md | 3 + npm/linux-arm64-gnu/package.json | 34 +++++ npm/linux-arm64-musl/README.md | 3 + npm/linux-arm64-musl/package.json | 34 +++++ npm/linux-x64-gnu/README.md | 3 + npm/linux-x64-gnu/package.json | 34 +++++ npm/linux-x64-musl/README.md | 3 + npm/linux-x64-musl/package.json | 34 +++++ npm/win32-arm64-msvc/README.md | 3 + npm/win32-arm64-msvc/package.json | 31 ++++ npm/win32-x64-msvc/README.md | 3 + npm/win32-x64-msvc/package.json | 31 ++++ package.json | 6 +- test/packaging.test.mjs | 124 +++++++++++++++ tools/binary.mjs | 28 ++++ tools/install.mjs | 74 +++++++++ tools/packages.mjs | 63 ++++++++ tools/platforms.mjs | 99 ++++++++++++ 24 files changed, 935 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 npm/darwin-arm64/README.md create mode 100644 npm/darwin-arm64/package.json create mode 100644 npm/darwin-x64/README.md create mode 100644 npm/darwin-x64/package.json create mode 100644 npm/linux-arm64-gnu/README.md create mode 100644 npm/linux-arm64-gnu/package.json create mode 100644 npm/linux-arm64-musl/README.md create mode 100644 npm/linux-arm64-musl/package.json create mode 100644 npm/linux-x64-gnu/README.md create mode 100644 npm/linux-x64-gnu/package.json create mode 100644 npm/linux-x64-musl/README.md create mode 100644 npm/linux-x64-musl/package.json create mode 100644 npm/win32-arm64-msvc/README.md create mode 100644 npm/win32-arm64-msvc/package.json create mode 100644 npm/win32-x64-msvc/README.md create mode 100644 npm/win32-x64-msvc/package.json create mode 100644 test/packaging.test.mjs create mode 100644 tools/binary.mjs create mode 100644 tools/install.mjs create mode 100644 tools/packages.mjs create mode 100644 tools/platforms.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8f65568 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,240 @@ +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. + - name: Build, inside Alpine + if: matrix.alpine != '' + run: | + docker run --rm -v "$PWD":/work -w /work \ + -e RUSTFLAGS="-C target-feature=-crt-static" \ + ${{ 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 diff --git a/README.md b/README.md index 68e9b6e..fd769f7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/npm/darwin-arm64/README.md b/npm/darwin-arm64/README.md new file mode 100644 index 0000000..2ae82ff --- /dev/null +++ b/npm/darwin-arm64/README.md @@ -0,0 +1,3 @@ +# `zudb-darwin-arm64` + +This is the **aarch64-apple-darwin** binary for `zudb` diff --git a/npm/darwin-arm64/package.json b/npm/darwin-arm64/package.json new file mode 100644 index 0000000..8ebd195 --- /dev/null +++ b/npm/darwin-arm64/package.json @@ -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" + ] +} diff --git a/npm/darwin-x64/README.md b/npm/darwin-x64/README.md new file mode 100644 index 0000000..a65b04f --- /dev/null +++ b/npm/darwin-x64/README.md @@ -0,0 +1,3 @@ +# `zudb-darwin-x64` + +This is the **x86_64-apple-darwin** binary for `zudb` diff --git a/npm/darwin-x64/package.json b/npm/darwin-x64/package.json new file mode 100644 index 0000000..21aaa1c --- /dev/null +++ b/npm/darwin-x64/package.json @@ -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" + ] +} diff --git a/npm/linux-arm64-gnu/README.md b/npm/linux-arm64-gnu/README.md new file mode 100644 index 0000000..462a726 --- /dev/null +++ b/npm/linux-arm64-gnu/README.md @@ -0,0 +1,3 @@ +# `zudb-linux-arm64-gnu` + +This is the **aarch64-unknown-linux-gnu** binary for `zudb` diff --git a/npm/linux-arm64-gnu/package.json b/npm/linux-arm64-gnu/package.json new file mode 100644 index 0000000..41990a0 --- /dev/null +++ b/npm/linux-arm64-gnu/package.json @@ -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" + ] +} diff --git a/npm/linux-arm64-musl/README.md b/npm/linux-arm64-musl/README.md new file mode 100644 index 0000000..677946b --- /dev/null +++ b/npm/linux-arm64-musl/README.md @@ -0,0 +1,3 @@ +# `zudb-linux-arm64-musl` + +This is the **aarch64-unknown-linux-musl** binary for `zudb` diff --git a/npm/linux-arm64-musl/package.json b/npm/linux-arm64-musl/package.json new file mode 100644 index 0000000..0be630a --- /dev/null +++ b/npm/linux-arm64-musl/package.json @@ -0,0 +1,34 @@ +{ + "name": "zudb-linux-arm64-musl", + "version": "0.0.1", + "cpu": [ + "arm64" + ], + "main": "zudb.linux-arm64-musl.node", + "files": [ + "zudb.linux-arm64-musl.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": [ + "musl" + ] +} diff --git a/npm/linux-x64-gnu/README.md b/npm/linux-x64-gnu/README.md new file mode 100644 index 0000000..ff5e9bd --- /dev/null +++ b/npm/linux-x64-gnu/README.md @@ -0,0 +1,3 @@ +# `zudb-linux-x64-gnu` + +This is the **x86_64-unknown-linux-gnu** binary for `zudb` diff --git a/npm/linux-x64-gnu/package.json b/npm/linux-x64-gnu/package.json new file mode 100644 index 0000000..1c4e118 --- /dev/null +++ b/npm/linux-x64-gnu/package.json @@ -0,0 +1,34 @@ +{ + "name": "zudb-linux-x64-gnu", + "version": "0.0.1", + "cpu": [ + "x64" + ], + "main": "zudb.linux-x64-gnu.node", + "files": [ + "zudb.linux-x64-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" + ] +} diff --git a/npm/linux-x64-musl/README.md b/npm/linux-x64-musl/README.md new file mode 100644 index 0000000..71d3591 --- /dev/null +++ b/npm/linux-x64-musl/README.md @@ -0,0 +1,3 @@ +# `zudb-linux-x64-musl` + +This is the **x86_64-unknown-linux-musl** binary for `zudb` diff --git a/npm/linux-x64-musl/package.json b/npm/linux-x64-musl/package.json new file mode 100644 index 0000000..eb22130 --- /dev/null +++ b/npm/linux-x64-musl/package.json @@ -0,0 +1,34 @@ +{ + "name": "zudb-linux-x64-musl", + "version": "0.0.1", + "cpu": [ + "x64" + ], + "main": "zudb.linux-x64-musl.node", + "files": [ + "zudb.linux-x64-musl.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": [ + "musl" + ] +} diff --git a/npm/win32-arm64-msvc/README.md b/npm/win32-arm64-msvc/README.md new file mode 100644 index 0000000..f12b632 --- /dev/null +++ b/npm/win32-arm64-msvc/README.md @@ -0,0 +1,3 @@ +# `zudb-win32-arm64-msvc` + +This is the **aarch64-pc-windows-msvc** binary for `zudb` diff --git a/npm/win32-arm64-msvc/package.json b/npm/win32-arm64-msvc/package.json new file mode 100644 index 0000000..d7d6c0a --- /dev/null +++ b/npm/win32-arm64-msvc/package.json @@ -0,0 +1,31 @@ +{ + "name": "zudb-win32-arm64-msvc", + "version": "0.0.1", + "cpu": [ + "arm64" + ], + "main": "zudb.win32-arm64-msvc.node", + "files": [ + "zudb.win32-arm64-msvc.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": [ + "win32" + ] +} diff --git a/npm/win32-x64-msvc/README.md b/npm/win32-x64-msvc/README.md new file mode 100644 index 0000000..b871451 --- /dev/null +++ b/npm/win32-x64-msvc/README.md @@ -0,0 +1,3 @@ +# `zudb-win32-x64-msvc` + +This is the **x86_64-pc-windows-msvc** binary for `zudb` diff --git a/npm/win32-x64-msvc/package.json b/npm/win32-x64-msvc/package.json new file mode 100644 index 0000000..8af008d --- /dev/null +++ b/npm/win32-x64-msvc/package.json @@ -0,0 +1,31 @@ +{ + "name": "zudb-win32-x64-msvc", + "version": "0.0.1", + "cpu": [ + "x64" + ], + "main": "zudb.win32-x64-msvc.node", + "files": [ + "zudb.win32-x64-msvc.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": [ + "win32" + ] +} diff --git a/package.json b/package.json index 7c32f48..f1051db 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,12 @@ "binaryName": "zudb", "dtsHeaderFile": "types/header.d.ts", "targets": [ - "aarch64-apple-darwin", - "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", + "aarch64-unknown-linux-musl", + "aarch64-apple-darwin", + "x86_64-apple-darwin", "x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc" ] diff --git a/test/packaging.test.mjs b/test/packaging.test.mjs new file mode 100644 index 0000000..7782956 --- /dev/null +++ b/test/packaging.test.mjs @@ -0,0 +1,124 @@ +// What a user gets when they type `npm i zudb`. +// +// None of this is exercised by importing the package on the machine +// that built it, which is exactly why it is tested: an install is the +// one thing that only goes wrong on somebody else's computer, and by +// then it has gone wrong on all of them. + +import assert from 'node:assert/strict' +import { readFile, readdir } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +import { PLATFORMS, binaryOf } from '../tools/platforms.mjs' + +const root = new URL('../', import.meta.url) + +async function json(path) { + return JSON.parse(await readFile(new URL(path, root), 'utf8')) +} + +test('installing builds nothing, so no compiler is needed and no script runs', async () => { + const pkg = await json('package.json') + + // The three npm runs on the user's machine at install time. A binding + // that needs any of them is a binding that needs a compiler, a + // network fetch, or the trust to run arbitrary code, and the whole + // point of shipping a binary per platform is that it needs none of + // the three. + for (const hook of ['preinstall', 'install', 'postinstall', 'prepare']) { + assert.equal(pkg.scripts?.[hook], undefined, `${hook} runs on the user's machine`) + } + + const named = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies, ...pkg.optionalDependencies }) + for (const gyp of ['node-gyp', 'node-gyp-build', '@mapbox/node-pre-gyp', 'prebuild-install', 'node-addon-api']) { + assert.equal(named.includes(gyp), false, `${gyp} is a build on the user's machine`) + } + + // The root package carries no binary. Every one of them is its own + // package, so npm downloads exactly the one the machine can run + // rather than all eight. + assert.deepEqual(pkg.files, ['index.js', 'index.d.ts', 'README.md', 'LICENSE']) +}) + +test('every target is built, published and asked for, or none of the three', async () => { + const pkg = await json('package.json') + + assert.deepEqual(pkg.napi.targets, PLATFORMS.map((platform) => platform.target)) + assert.deepEqual( + (await readdir(new URL('npm/', root), { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(), + PLATFORMS.map((platform) => platform.dir).sort(), + ) +}) + +test('the optional dependencies are written at publish time, not checked in', async () => { + const pkg = await json('package.json') + + // The eight platform packages are what an install resolves one of, and + // naming them here would be the obvious way to say so. It is also a + // lock file npm cannot complete: a package that has not been published + // yet resolves to nothing, `npm install` records the range and no + // entry, and every `npm ci` after it fails with "missing from lock + // file" on all eight. `napi pre-publish` writes the block from + // napi.targets at the version being released, which is the same list + // this suite holds the npm directories to. + assert.equal(pkg.optionalDependencies, undefined) + + const lock = await json('package-lock.json') + assert.equal(lock.packages[''].optionalDependencies, undefined) +}) + +test('a platform package says which machine it is for and holds one file', async () => { + const pkg = await json('package.json') + + for (const platform of PLATFORMS) { + const each = await json(`npm/${platform.dir}/package.json`) + const binary = binaryOf(platform) + + assert.equal(each.name, `zudb-${platform.dir}`) + // The same version as the root package, and exactly, because the + // binary and the loader that dlopens it are one build cut in two. + // A range here is a mix of two builds that npm would resolve + // quietly. + assert.equal(each.version, pkg.version) + + assert.deepEqual(each.os, [platform.os]) + assert.deepEqual(each.cpu, [platform.cpu]) + assert.deepEqual(each.libc, platform.libc ? [platform.libc] : undefined) + assert.equal(each.main, binary) + assert.deepEqual(each.files, [binary]) + assert.equal(each.license, 'Apache-2.0') + assert.deepEqual(each.engines, pkg.engines) + } +}) + +test('the loader asks for the packages that are published', async () => { + const loader = await readFile(new URL('index.js', root), 'utf8') + + // The loader is generated and the packages are generated, from the + // same list, but not by the same command and not at the same time. + // What goes wrong is a name that agrees with nothing: the package + // publishes as one thing and the require asks for another, and every + // install on that platform falls through to the error at the end of + // the loader. + for (const platform of PLATFORMS) { + assert.ok( + loader.includes(`require('zudb-${platform.dir}')`), + `the loader never requires zudb-${platform.dir}`, + ) + } +}) + +test('the built binary is the one the loader looks for first', async () => { + const here = await readdir(fileURLToPath(root)) + const built = here.filter((name) => name.endsWith('.node')) + + // One local build, beside the loader, which is what `napi build` + // leaves and what the tests in this suite load. It is not published: + // it is how a checkout runs without publishing anything. + assert.equal(built.length, 1, `expected one built addon, found ${built.join(', ') || 'none'}`) + assert.match(built[0], /^zudb\.[a-z0-9-]+\.node$/) +}) diff --git a/tools/binary.mjs b/tools/binary.mjs new file mode 100644 index 0000000..193d998 --- /dev/null +++ b/tools/binary.mjs @@ -0,0 +1,28 @@ +// The addon a build row produced is the one that row was for. +// +// `napi build` names the file after the target it built, so a row that +// built the wrong thing produces a file with the wrong name and an +// upload that succeeds. This is what turns that into a failure on the +// row that did it, rather than a platform package that is quietly empty +// three jobs later. +// +// node tools/binary.mjs x86_64-unknown-linux-musl + +import { access } from 'node:fs/promises' + +import { binaryOf, platformOf } from './platforms.mjs' + +const [target] = process.argv.slice(2) +if (!target) { + console.error('usage: node tools/binary.mjs ') + process.exit(2) +} + +const binary = binaryOf(platformOf(target)) +try { + await access(new URL(`../${binary}`, import.meta.url)) +} catch { + console.error(`${target} built no ${binary}`) + process.exit(1) +} +console.log(`${target} built ${binary}`) diff --git a/tools/install.mjs b/tools/install.mjs new file mode 100644 index 0000000..8c041bd --- /dev/null +++ b/tools/install.mjs @@ -0,0 +1,74 @@ +// What `npm i zudb` does, done for real, on the machine running this. +// +// Everything else about packaging is read off manifests, and a manifest +// can be right in every field and still install into something that +// cannot be required. So this packs the two tarballs npm would fetch, +// installs them into an empty project outside this checkout, and runs a +// statement through what came out. It also proves the part that has no +// other way of being tested: the seven platform packages that do not +// exist yet are optional, and an install that cannot fetch them is +// expected to carry on rather than fail. +// +// node tools/install.mjs + +import { execFileSync } from 'node:child_process' +import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { binaryOf, hostPlatform } from './platforms.mjs' + +const root = fileURLToPath(new URL('../', import.meta.url)) +const platform = hostPlatform() + +// The binary belongs to the platform package, which is where a release +// puts it and where npm would fetch it from. A checkout has it beside +// the loader instead, so say so rather than failing inside npm pack. +const binary = binaryOf(platform) +try { + await access(join(root, 'npm', platform.dir, binary)) +} catch { + console.error(`npm/${platform.dir} holds no ${binary}: run npm run build, then cp ${binary} npm/${platform.dir}/`) + process.exit(1) +} + +// Outside the checkout, because npm walks upwards looking for a project +// to belong to and would find this one. +const where = await mkdtemp(join(tmpdir(), 'zudb-install-')) +const app = join(where, 'app') + +function npm(args, cwd) { + return execFileSync('npm', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }) +} + +try { + // The root package, which carries the loader and no binary, and the + // one platform package this machine can use. + const packed = [root, join(root, 'npm', platform.dir)].map((each) => + join(where, npm(['pack', '--silent', '--pack-destination', where, each], root).trim()), + ) + + await mkdir(app) + npm(['init', '-y'], app) + npm(['install', '--no-audit', '--no-fund', ...packed], app) + + await writeFile( + join(app, 'run.mjs'), + [ + "import assert from 'node:assert/strict'", + "import { connect } from 'zudb'", + "const conn = await connect(':memory:')", + 'await conn.exec("INSERT (:person {id: 1, name: \'ada\'})")', + "const rows = await conn.query('MATCH (p:person) RETURN p.name AS name')", + "assert.deepEqual(rows.columns, ['name'])", + "assert.equal(rows[0].name, 'ada')", + 'await conn.close()', + ].join('\n'), + ) + execFileSync(process.execPath, [join(app, 'run.mjs')], { cwd: app, stdio: 'inherit' }) + + console.log(`installed zudb and zudb-${platform.dir} into an empty project, and it ran a statement`) +} finally { + await rm(where, { recursive: true, force: true }) +} diff --git a/tools/packages.mjs b/tools/packages.mjs new file mode 100644 index 0000000..3197d3f --- /dev/null +++ b/tools/packages.mjs @@ -0,0 +1,63 @@ +// Every platform package holds the binary it names. +// +// The set is what matters, which is why this is a tool and not eight +// assertions in eight jobs: a release that ships seven of eight +// packages installs on the eighth platform, resolves no binary, and +// fails at the require with a message about a package that exists. +// This is the last place that can be caught before publishing, and +// publishing is the step that cannot be taken back. +// +// It runs after `napi pre-publish`, which lays the binaries into the +// packages and writes the optional dependencies into the root manifest. +// Before that step half of what it looks at does not exist yet. +// +// node tools/packages.mjs + +import { readFile, stat } from 'node:fs/promises' + +import { PLATFORMS, binaryOf } from './platforms.mjs' + +const root = new URL('../', import.meta.url) +const pkg = JSON.parse(await readFile(new URL('package.json', root), 'utf8')) + +const wrong = [] +for (const platform of PLATFORMS) { + const name = `zudb-${platform.dir}` + const binary = binaryOf(platform) + const where = new URL(`npm/${platform.dir}/`, root) + + let each + try { + each = JSON.parse(await readFile(new URL('package.json', where), 'utf8')) + } catch { + wrong.push(`${name} has no package.json`) + continue + } + if (each.name !== name) wrong.push(`${platform.dir} publishes as ${each.name}`) + // Exactly the root's version. The binary and the loader that dlopens + // it are one build cut in two, and a range here is two builds npm + // would mix quietly. + if (each.version !== pkg.version) { + wrong.push(`${name} is ${each.version} and the root package is ${pkg.version}`) + } + // Written by pre-publish rather than checked in, so this is also the + // check that pre-publish ran at all. + if (pkg.optionalDependencies?.[name] !== pkg.version) { + wrong.push(`${name} is not an optional dependency at ${pkg.version}`) + } + + try { + const { size } = await stat(new URL(binary, where)) + // A zero length file is what a failed copy leaves, and it packs and + // publishes exactly as well as a real one. + if (size === 0) wrong.push(`${name} holds an empty ${binary}`) + } catch { + wrong.push(`${name} holds no ${binary}`) + } +} + +if (wrong.length) { + for (const line of wrong) console.error(line) + process.exit(1) +} +console.log(`${PLATFORMS.length} platform packages, each with its binary`) diff --git a/tools/platforms.mjs b/tools/platforms.mjs new file mode 100644 index 0000000..47d9763 --- /dev/null +++ b/tools/platforms.mjs @@ -0,0 +1,99 @@ +// The platforms this package ships a binary for, in one place. +// +// These are 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. The release workflow builds this list, the test suite holds +// package.json and the npm directories to it, and the tool beside this +// one checks that every row ended up with a binary in it. +// +// The mapping from a Rust target triple to npm's `os`, `cpu` and `libc` +// is a table of facts rather than something to derive. A wrong entry is +// a package that installs on the wrong machine and fails at the +// require, which is the one failure a user cannot do anything about. +export const PLATFORMS = [ + { + target: 'x86_64-unknown-linux-gnu', + dir: 'linux-x64-gnu', + os: 'linux', + cpu: 'x64', + libc: 'glibc', + }, + { + target: 'aarch64-unknown-linux-gnu', + dir: 'linux-arm64-gnu', + os: 'linux', + cpu: 'arm64', + libc: 'glibc', + }, + { + target: 'x86_64-unknown-linux-musl', + dir: 'linux-x64-musl', + os: 'linux', + cpu: 'x64', + libc: 'musl', + }, + { + target: 'aarch64-unknown-linux-musl', + dir: 'linux-arm64-musl', + os: 'linux', + cpu: 'arm64', + libc: 'musl', + }, + { + target: 'aarch64-apple-darwin', + dir: 'darwin-arm64', + os: 'darwin', + cpu: 'arm64', + libc: null, + }, + { + target: 'x86_64-apple-darwin', + dir: 'darwin-x64', + os: 'darwin', + cpu: 'x64', + libc: null, + }, + { + target: 'x86_64-pc-windows-msvc', + dir: 'win32-x64-msvc', + os: 'win32', + cpu: 'x64', + libc: null, + }, + { + target: 'aarch64-pc-windows-msvc', + dir: 'win32-arm64-msvc', + os: 'win32', + cpu: 'arm64', + libc: null, + }, +] + +/// The row for the machine this is running on, which is the one package +/// an install here can use. glibc and musl are the same `process.platform` +/// and the same `process.arch`, and the only thing in Node that tells them +/// apart is whether the process reports a glibc it was linked against. +export function hostPlatform() { + const os = process.platform + const cpu = process.arch + const libc = os !== 'linux' ? null : process.report.getReport().header.glibcVersionRuntime ? 'glibc' : 'musl' + + const found = PLATFORMS.find( + (platform) => platform.os === os && platform.cpu === cpu && platform.libc === libc, + ) + if (!found) throw new Error(`no platform row for ${os} ${cpu}${libc ? ` ${libc}` : ''}`) + return found +} + +/// The row for a target triple, or a failure naming what was asked for. +export function platformOf(target) { + const found = PLATFORMS.find((platform) => platform.target === target) + if (!found) throw new Error(`no platform row for ${target}`) + return found +} + +/// What the addon for a platform is called, which is the name the +/// loader looks for and the only file its package publishes. +export function binaryOf(platform) { + return `zudb.${platform.dir}.node` +} From 869a4b5cde775eefde295c009a97e14647aa0b5c Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:08:53 +0700 Subject: [PATCH 2/3] name the linker for the arm64 musl build Rust's aarch64-unknown-linux-musl target asks for aarch64-linux-musl-gcc, which is the name a cross toolchain gives its compiler. Inside Alpine on an arm64 machine there is no cross toolchain and the compiler is called gcc, so the build got as far as linking the first proc macro and stopped. The x86_64 musl target asks for cc and found it, which is why seven of the eight rows were green. --- .github/workflows/release.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f65568..a107836 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -143,12 +143,15 @@ jobs: # 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. + # statically into everything. The linker is named because 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. - name: Build, inside Alpine if: matrix.alpine != '' run: | docker run --rm -v "$PWD":/work -w /work \ - -e RUSTFLAGS="-C target-feature=-crt-static" \ + -e RUSTFLAGS="-C target-feature=-crt-static -C linker=gcc" \ ${{ matrix.alpine }} sh -c ' set -eu apk add --no-cache nodejs npm rustup build-base From 42a1b9f83d92d10d03e771316d2ff77983df4736 Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:14:52 +0700 Subject: [PATCH 3/3] name the arm64 musl linker where cargo will read it The flag went into RUSTFLAGS, which cargo stops applying to host artifacts as soon as a target is named, and napi names one. The host artifacts were the proc macro build scripts, so the build died in the same place with the same message. A per target linker applies to every unit built for that triple, host ones included. --- .github/workflows/release.yml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a107836..1320b51 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -143,15 +143,23 @@ jobs: # 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 because 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. + # 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 -C linker=gcc" \ + -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