Skip to content

Native N-API backend for libpg_query (PG 18) with jemalloc#1

Open
Ben Asher (benasher44) wants to merge 21 commits into
mainfrom
napi-jemalloc
Open

Native N-API backend for libpg_query (PG 18) with jemalloc#1
Ben Asher (benasher44) wants to merge 21 commits into
mainfrom
napi-jemalloc

Conversation

@benasher44

@benasher44 Ben Asher (benasher44) commented Jun 28, 2026

Copy link
Copy Markdown

Summary

Adds a native N-API backend for libpg_query (PG 18) under native/, as a
memory-efficient, drop-in alternative to the existing WASM build. It ships as
prebuilt platform binaries (esbuild-style) — no node-gyp or compiler toolchain at
install time — and is Node-only (no browser/Deno/Workers targets).

This is a draft: nothing is published to npm yet, and Linux/musl prebuilds are
produced in CI only (the release workflow is wired but unrun).

Native vs WASM

Head-to-head on the same machine (darwin-arm64, Node 24), parsing a 3.31 MB SQL
query (1500× UNION ALL, ~65 MB JSON parse tree), 3 parse/free cycles. Each backend
measured in its own process. "Retained" is RSS after the result is dropped and GC
settles; small-query throughput is SELECT id, name FROM users WHERE id = $1 ×10k.

Backend idle RSS peak RSS (max of 3) retained after free throughput
WASM (@libpg-query/parser) 93 MB 1359 MB +1202 MB (never shrinks) 125k/s
Native — system malloc 53 MB 932 MB +812 MB (ratchets up) 139k/s
Native — jemalloc 55 MB 498 MB +377 MB (stabilizes) 139k/s

Per-cycle peak progression tells the real story:

WASM:            1261 → 1359 → 1359 MB   (plateaus at a high permanent floor)
Native system:    649 →  867 →  932 MB   (fragments, still climbing)
Native jemalloc:  381 →  497 →  498 MB   (flat after cycle 2)
  • WASM's memory never comes back. WebAssembly linear memory only ever grows, so
    the ~1.2 GB consumed by one big parse is held for the lifetime of the process. No
    allocator choice can change this; it's a property of the WASM memory model.
  • Native + system malloc is better but still ratchets — glibc/libmalloc fragments
    under this workload and keeps climbing across cycles.
  • Native + jemalloc is the win: ~2.7× lower peak than WASM, returns freed pages to
    the OS, and stabilizes at ~498 MB instead of growing. It also has a far lower idle
    baseline (55 MB vs WASM's 93 MB, since there's no preallocated heap).
  • Throughput: native is ~11% faster than WASM on small queries and identical
    across allocators — jemalloc is a pure memory win with no speed cost.

Reproduce: node --expose-gc native/benchmark/memory.mjs --all --cycles 3 (with
@libpg-query/parser installed) and bash native/benchmark/compare-allocators.sh --cycles 3.

jemalloc: why preload-only (not statically linked)

We tried to bake jemalloc into the .node binary so users would get the benefit for
free. Every approach failed, and the failure is fundamental:

  • --with-jemalloc-prefix=je_ + remapping malloc/free in libpg_query: libpg_query's
    internal strdup() still calls libc malloc, but free got remapped to je_free
    — allocator mismatch → crash.
  • No-prefix jemalloc with -fvisibility=hidden / -exported_symbols_list /
    __DATA,__interpose: either fails to intercept libc-internal allocations, or (on the
    interpose path) hijacks malloc for the entire process including V8, which a
    library addon has no business doing.

To work correctly, jemalloc must replace malloc/free for the whole process (so
libc-internal allocations like strdup are consistent), and that can only be done
safely at process startup via LD_PRELOAD / DYLD_INSERT_LIBRARIES — not by linking
into a dylib. So the addon uses the system allocator by default and documents the
one-line preload to opt into jemalloc:

# Linux
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 node app.js
# macOS
DYLD_INSERT_LIBRARIES=$(brew --prefix jemalloc)/lib/libjemalloc.dylib node app.js
RUN apt-get install -y libjemalloc2
ENV LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2

MALLOC_CONF=dirty_decay_ms:0,muzzy_decay_ms:0 trims peak a little further (~477 MB
in the run above) and is worth setting in memory-sensitive deployments.

What's in the PR

Native addon (native/src/addon.cc)

  • Wraps pg_query_parse, _fingerprint, _normalize, _scan, _parse_plpgsql.
  • Returns {error, result} to JS rather than throwing from C++ — throwing N-API errors
    with attached detail objects segfaulted when Node's inspector/assert traversed them.
    The JS layer parses the error and throws a plain SqlError.

Distribution (esbuild-style)

  • platforms.json is the single source of truth for all 5 targets (darwin
    arm64, linux x64/arm64, linux x64/arm64-musl): os/cpu/libc plus CI runner/container.
    The runtime loader, the platform-package generator, and the optionalDependencies sync
    script all read from it. Adding a platform is a one-line change.
  • glibc and musl Linux packages are marked mutually exclusive via libc, so npm 10+
    and Yarn Berry install only the binary matching the host. Cross-arch install flags
    documented for Docker-on-Mac builds. At runtime, libc is detected via Node's
    process.report glibc marker (reliable in distroless/scratch images that ship no ldd).
  • Published under the @ashbyhq scope as public packages: the main package
    (@ashbyhq/libpg-query-native) and all per-platform packages set
    publishConfig.access: public, and the release workflow runs npm publish --access public.
    Requires an NPM_TOKEN with publish rights to the @ashbyhq org.

CI/CD (.github/workflows/)

  • native-build.yml — reusable workflow; derives its build matrix from platforms.json,
    builds + tests on every platform, uploads prebuilds.
  • native-ci.yml — runs the build, then a package smoke test: packs the main +
    platform tarballs, installs them in an isolated dir, and exercises every public API on
    each platform.
  • native-release.yml — manual dispatch with a dry-run toggle; builds all platforms
    and publishes the main + per-platform packages to npm. Needs an NPM_TOKEN secret.
    Supports prerelease publishes via npm dist-tags: pass a tag (e.g. beta) or leave
    it blank to auto-derive (latest for X.Y.Z, the prerelease id for X.Y.Z-beta.N), with
    a guard that refuses to push a prerelease onto latest.
  • native-benchmark.yml — tracks 4 smaller-is-better metrics (large-query parse time,
    peak RSS, retained RSS, small-query latency) on a fixed runner under jemalloc, against
    a gh-pages baseline. Posts per-metric diffs to the run summary every PR; fails only on
    a conservative 2× regression (alert-threshold: 200%).
  • All actions pinned to current majors (checkout@v7, setup-node@v6, upload-artifact@v7, download-artifact@v8).

Tests — 46 unit tests (parsing/fingerprint/normalize/plpgsql/scan/errors) + the
cross-platform smoke test.

Performance investigation (negative results, for the record)

Two further optimizations beyond the allocator were explored and rejected:

  • protobuf output path (pg_query_parse_protobuf → decode in JS): 3× slower
    end-to-end. The C-side protobuf serialization is ~7× costlier than libpg_query's JSON
    string builder, dwarfing the faster JS-side decode. (Useful only for compact
    storage/IPC — 74% smaller wire size — so not included.)
  • simdjson: 2.3× slower than V8's JSON.parse for full materialization. The cost
    is creating ~4.2M V8 objects through N-API, which V8's internal JSON fast-path beats.

For large queries the dominant cost is V8 building the object graph (JSON.parse ≈ 66%
of end-to-end), already at V8's floor. The allocator is the lever that matters;
everything else is noise for realistic query sizes (~5 µs).

Not included / follow-ups

  • Nothing published to npm (draft).
  • NPM_TOKEN secret needs to be added to the repo before the release workflow can publish.
  • Linux/musl prebuilds exist only as CI artifacts so far.

Test plan

  • 46 unit tests pass locally (darwin-arm64)
  • Package smoke test passes against packed tarballs (full flow validated locally)
  • Allocator + WASM metrics reproduced (memory.mjs --all --cycles 3, compare-allocators.sh)
  • CI green across all 6 platforms
  • Release workflow dry-run

🤖 Generated with Claude Code

Ben Asher (benasher44) and others added 11 commits June 28, 2026 14:04
Native Node addon wrapping libpg_query via N-API, as a memory-efficient
alternative to the WASM build. Ships prebuilt platform binaries via
esbuild-style optional dependencies (@ashbyhq/libpg-query-native-<platform>),
so consumers never need node-gyp.

API is a drop-in replacement for @libpg-query/parser: parse, fingerprint,
normalize, scan, parsePlPgSQL (sync + async variants).

For optimal memory behavior with large queries, jemalloc should be preloaded
via LD_PRELOAD (Linux) or DYLD_INSERT_LIBRARIES (macOS). Includes memory
and throughput benchmarks comparing allocators.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests system malloc, jemalloc, mimalloc, and tcmalloc via LD_PRELOAD.
Supports multiple parse/free cycles (--cycles N) to reveal ratchet behavior,
and auto-detects which allocator is active from environment variables.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…kend

Builds on the native N-API backend with the release/distribution plumbing:

- platforms.json as the single source of truth for the 6 target platforms
  (os/cpu/libc + CI runner/container). package-platforms.mjs, the runtime
  loader in index.ts, and sync-optional-deps.mjs all read from it, so adding
  a platform is a one-line change.
- Mark glibc vs musl Linux packages mutually exclusive via `libc`, so npm 10+
  and Yarn Berry install only the binary matching the host.
- native-build.yml: reusable workflow that derives its matrix from
  platforms.json, builds + tests on every platform, uploads prebuilds.
- native-ci.yml: runs the build, then a package smoke test that installs the
  generated platform package in an isolated dir and exercises every public API.
- native-release.yml: manual dispatch (with dry-run) that builds all platforms
  and publishes the main + per-platform packages to npm.
- README: replace prior unverified memory figures with reproducible,
  methodology-labeled system-malloc-vs-jemalloc measurements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three issues surfaced on the first CI run:

- WASM CI (ci.yml) broke because `native` was added to pnpm-workspace.yaml
  but the committed pnpm-lock.yaml has no native importer, so the
  frozen-lockfile install failed. The native package is standalone (npm +
  make, with unpublished platform optionalDependencies) and doesn't belong
  in the pnpm WASM monorepo. Remove it from the workspace.
- glibc Linux jobs ran the Alpine `apk add` step and failed: the matrix
  derived musl from `!!v.libc`, but glibc platforms now carry
  `libc: ["glibc"]`. Detect musl via `(v.libc||[]).includes('musl')`.
- musl builds wrote to prebuilds/linux-x64 (no suffix) because the
  Makefile's `ldd --version` parsing didn't detect musl in node:20-alpine,
  while the JS loader correctly looked in linux-x64-musl. Detect musl via
  the presence of /lib/ld-musl-*, make PLATFORM_KEY overridable, and pin it
  from the CI matrix (make build PLATFORM_KEY=<platform>).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The package-test job only runs after builds pass, so it wasn't exercised on
earlier runs. Reworked it to be robust and validated the full flow locally:

- Install via `npm pack` tarballs (main + platform) instead of installing the
  local source dir. Installing the source pulls the unpublished platform
  optionalDependencies from the registry (404s); tarballs + `--omit=optional`
  avoid that and install the matching platform binary explicitly.
- `npm pack --pack-destination` does not create its target dir, so `mkdir -p`
  it first (this was the concrete failure reproduced locally).
- Drop `--install-strategy=nested`; default hoisting resolves correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- actions/checkout v4 -> v5 (Node 24 runtime)
- actions/setup-node v4 -> v6 (latest, v6.2.0)
- actions/upload-artifact v4 -> v5
- actions/download-artifact v4 -> v5 (kept in lockstep with upload; the
  artifact actions require matching majors for up/download)

setup-node v5+ auto-enables dependency caching, which errors when no lockfile
is present — and native/ doesn't commit package-lock.json. Set
package-manager-cache: false on each setup-node to preserve v4 behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the native-only allocator table with a 3-way comparison (WASM,
native system malloc, native jemalloc) using isolated per-process
measurements: WASM peaks at 1359 MB and never releases (+1202 MB retained),
native+jemalloc peaks at 498 MB and stabilizes (+377 MB), and native is
~11% faster on throughput.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verified against each action's releases/latest:
- actions/checkout v5 -> v7
- actions/upload-artifact v5 -> v7
- actions/download-artifact v5 -> v8 (upload v7 + download v8 is the current
  compatible pair per the GitHub Actions changelog; default archive:true keeps
  existing zip behavior, so no flow change)
- actions/setup-node stays v6 (latest is v6.4.0)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
JavaScript actions (checkout/setup-node/upload-artifact) can't run inside an
Alpine container on arm64 runners — GitHub injects a glibc Node that fails
with "JavaScript Actions in Alpine containers are only supported on x64 Linux
runners." This broke the linux-arm64-musl build.

Drop the job-level `container: node:20-alpine` for both the build and
package-test jobs. The host actions now run on the glibc runner, and the musl
build/test/smoke runs via `docker run "${{ matrix.container }}"` — which pulls
the arch-matching Alpine image on both x64 and arm64 runners.

Validated the full arm64-musl flow locally (Apple Silicon → arm64 Alpine):
libpg_query + addon compile, 46 tests pass, output is a musl aarch64 ELF.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Bump all builds to Node 24: setup-node node-version 24, and the musl Alpine
  images to node:24-alpine (via platforms.json container).
- Drop the darwin-x64 target. Apple Silicon (darwin-arm64) is the only macOS
  variant built; the sole remaining macOS runner (macos-14) is used only for
  that explicit variant. Everything else runs on Ubuntu.
- Re-sync optionalDependencies and update the platform table accordingly.

Targets are now: darwin-arm64, linux-x64, linux-arm64, linux-x64-musl,
linux-arm64-musl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove the WASM build/test workflows — ci.yml, build-wasm.yml, and
build-wasm-no-docker.yaml — so no WASM builds run in CI. The WASM packages
(full/, parser/, versions/, types/, enums/, protos/) and the rest of the
monorepo are kept intact; they can still be built manually. Only the native
backend's workflows (native-ci.yml, native-build.yml, native-release.yml) run
in CI now.

This reverts the full WASM removal from the previous commit, keeping WASM
support while dropping just its automated builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ben Asher (benasher44) and others added 4 commits June 28, 2026 21:59
New native-benchmark.yml runs benchmark/ci-bench.mjs on a fixed runner
(ubuntu-24.04, under jemalloc) for every PR and push to main, tracking four
smaller-is-better metrics against a gh-pages baseline:
- large-query parse time (ms)
- large-query peak RSS (MB)
- large-query retained RSS (MB)
- small-query latency (us/parse)

Per-metric diffs are posted to the run summary every run (summary-always);
a regression beyond 2x the baseline comments on the PR and fails the check
(fail-on-alert, alert-threshold: 200%) — conservative to tolerate shared-runner
noise. Baseline is written to gh-pages only on push to main; PRs compare only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a prominent note at the top of the root README stating that this fork
builds, tests, and publishes only @ashbyhq/libpg-query-native (a native N-API
addon for Node.js, PG 18) — no WASM/browser/Deno/Worker builds and no WASM
build in CI. The upstream WASM packages remain in-tree for reference but are
not built or published here; point readers to native/README.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Pin typescript to exact 5.9.3 in native devDependencies (was ^5.3.3) so the
  compiler version is deterministic across local and CI builds.
- Replace every `npx tsc` in the workflows with `npm run build:ts`, which runs
  the pinned local binary from node_modules/.bin (no on-the-fly npx fetch).

Audited the repo: these were the only npx usages; none remain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
isMusl() shelled out to `ldd --version` and treated any failure as musl.
Minimal images (distroless/scratch) ship no `ldd`, so the call throws and
a glibc host is misdetected as musl — selecting a platform package/prebuild
that doesn't exist and failing at load with "Native addon not found for
linux-x64-musl".

Use Node's process.report.header.glibcVersionRuntime (set on glibc, absent
on musl), with a /lib/ld-musl-* fallback and a glibc default. Verified:
distroless debian13 now resolves linux-x64; node:alpine still resolves musl.
Bump to 0.1.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
native: detect libc via process.report, not `ldd`
@benasher44 Ben Asher (benasher44) marked this pull request as ready for review June 30, 2026 16:17
The release workflow already passes `npm publish --access public`, but the main
package.json lacked publishConfig, so a manual `npm publish` would default to
restricted for a scoped (@ashbyhq) package. Add publishConfig.access=public to
match the generated platform packages and make scoped public publishing the
default regardless of how it's invoked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 92d6c209-6e13-4501-b2c4-9ab2a216bf4e

📥 Commits

Reviewing files that changed from the base of the PR and between 6316f19 and 34fa0df.

📒 Files selected for processing (1)
  • native/package.json

📝 Walkthrough

Walkthrough

Removes the legacy WASM workflow files and adds a native Node-API addon stack with build, packaging, testing, benchmarking, documentation, and release automation.

Changes

Native N-API addon migration

Layer / File(s) Summary
Documentation updates
README.md, native/README.md
The root README adds a native-only notice and install pointer. native/README.md documents memory behavior, installation, API usage, jemalloc setup, benchmarks, and source builds.
Platform config and build setup
native/.gitignore, native/platforms.json, native/Makefile, native/package.json, native/tsconfig.json
Defines supported platforms, native build outputs, package metadata, TypeScript compilation settings, and native build ignore rules.
C++ addon entrypoints
native/src/addon.cc
Implements the synchronous N-API entrypoints for parsing, PL/pgSQL parsing, fingerprinting, normalization, and scanning, including JSON error serialization, token mapping, and module registration.
TypeScript API and loader
native/src/index.ts
Exports the TypeScript error and scan types, loads the native addon with platform and musl detection, and wraps the native sync functions with async delegations and structured error handling.
Platform packaging scripts
native/scripts/package-platforms.mjs, native/scripts/sync-optional-deps.mjs
Generates per-platform npm package directories from prebuilds and rewrites the root package’s optional dependencies to match the platform map.
Unit and smoke tests
native/test/*.test.js, native/test/smoke.mjs
Adds test coverage for parsing, PL/pgSQL parsing, fingerprinting, normalization, scanning, error handling, and the packaged smoke path.
Benchmark scripts
native/benchmark/*.mjs, native/benchmark/*.sh
Adds benchmark scripts for native versus JSON parsing, CI regression output, RSS comparison, and allocator comparison runs with jemalloc support.
Native CI, benchmark, and release workflows
.github/workflows/native-*.yml
Adds reusable native build, CI, benchmark, and release workflows.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding a native N-API libpg_query backend with jemalloc support.
Description check ✅ Passed The description is detailed and directly matches the native backend, packaging, CI, benchmarking, and jemalloc changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch napi-jemalloc

Comment @coderabbitai help to get the list of available commands.

The release workflow published everything to the 'latest' dist-tag, so a
prerelease (e.g. 0.2.0-beta.0) would have hijacked 'latest'. Add dist-tag
support:

- New optional `tag` dispatch input.
- When blank, auto-derive: 'latest' for X.Y.Z, or the leading alpha
  prerelease id for X.Y.Z-<id>.N (0.2.0-beta.1 -> beta, -rc -> rc, numeric
  prerelease -> beta).
- Publish main + platform packages with `npm publish --access public --tag $NPM_TAG`.
- Guard: refuse to publish a prerelease onto 'latest'.
- Dry-run summary prints the resolved dist-tag.

Derivation uses pure shell case/parameter-expansion (no grep/sed) for
portability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/native-benchmark.yml:
- Around line 19-21: The workflow-level permissions block is too broad; move the
`contents: write` and `pull-requests: write` permissions into the `benchmark`
job only. Update the `.github/workflows/native-benchmark.yml` job definition so
the job that runs the benchmark, pushes the baseline, and comments on PRs owns
these permissions, while the rest of the workflow remains unprivileged.

In @.github/workflows/native-build.yml:
- Around line 1-9: Add a top-level least-privilege permissions block to the
Native Build reusable workflow so it runs with contents: read instead of default
token scopes. Also update the action references in this workflow to comply with
the repo’s pin-to-hash policy by replacing the unpinned version tags used by the
workflow steps with pinned references; check the workflow actions around the
reusable native build jobs and keep the change scoped to the existing workflow
definition.
- Around line 53-72: The native build workflow is injecting untrusted matrix
values directly into shell in the glibc and musl steps. Update the `Build and
test` jobs to move `matrix.platform` and `matrix.container` into step `env`
variables, then reference those shell variables inside `run` and `docker run`
instead of expanding `${{ matrix.* }}` in the command strings. Keep the fix
localized around the `Build and test (glibc / macOS)` and `Build and test (musl,
in Alpine container)` steps in `native-build.yml`.

In @.github/workflows/native-ci.yml:
- Around line 23-42: The package-test-matrix job duplicates the matrix-building
logic already defined in the native build workflow. Move the matrix generation
into the reusable workflow as a workflow_call output from the existing matrix
job, then update package-test-matrix to consume that output instead of
re-implementing the Node-based generation; use the matrix and
package-test-matrix job names to locate the change.
- Around line 74-106: The native CI workflow still interpolates untrusted matrix
values directly into shell commands, creating the same template-injection risk
as the other workflow. Update the Pack/install/smoke-test steps in native-ci to
pass matrix.platform and matrix.container through env: and reference only shell
variables inside the run scripts, including the docker run command and the
package directory/pack paths, so values sourced from platforms.json are not
expanded by GitHub Actions expressions in the shell.

In @.github/workflows/native-release.yml:
- Around line 55-60: The “Set version” and tag steps in the native-release
workflow interpolate inputs.version directly into shell commands, so bind it
once via env and reference a quoted VERSION variable in those run blocks
instead. Update the existing “Set version” step and the later tag step that uses
npm version / git tag logic to read from env rather than inline expression
interpolation, keeping the commands otherwise unchanged.

In `@native/benchmark/memory.mjs`:
- Around line 66-70: The parse/import failure handling in memory.mjs is
swallowing errors by returning null from the backend parsing paths, which causes
main() to silently skip requested benchmark backends. Update the affected
backend parse/import helpers and the main flow so requested backends fail
explicitly instead of converting failures to null, and use the existing
backend-specific functions in this file to surface the error and stop the run
when a requested backend cannot be benchmarked.

In `@native/Makefile`:
- Around line 63-65: The `$(LIBPG_QUERY_DIR)` target can be left in a partial
state if `git clone` is interrupted, causing Make to think the target is already
satisfied on later runs. Update the `$(LIBPG_QUERY_DIR)` recipe to clone into a
temporary directory and only move/rename it into `$(LIBPG_QUERY_DIR)` after a
successful clone. Keep the fix localized to the Makefile rule that currently
runs `git clone`, so repeated invocations recover cleanly from interrupted
downloads.

In `@native/README.md`:
- Around line 117-132: The README’s jemalloc preload example hard-codes an
x86_64 Linux path that won’t work on arm64 hosts, so update the native addon
documentation to use architecture-aware Linux paths or separate examples for x64
and arm64. Adjust the Linux preload snippet and the Dockerfile guidance together
so they match the supported Linux architectures mentioned in the native addon
docs and avoid referencing a single fixed /usr/lib/x86_64-gnu path.

In `@native/src/addon.cc`:
- Around line 9-23: The JSON escaping in EscapeJsonString is incomplete because
it only handles a few named escapes and leaves other control bytes raw, which
can break the error payload consumed by checkError in native/src/index.ts.
Update EscapeJsonString in addon.cc to escape every character below 0x20,
including cases like backspace, form feed, and any other control byte, so the
serialized error object always remains valid JSON. Use the existing
EscapeJsonString helper as the single place to fix this, and add the needed
low-level formatting support if required.

In `@native/src/index.ts`:
- Around line 138-140: The async wrappers in native/src/index.ts are only
compatibility shims because parse, parsePlPgSQL, fingerprint, normalize, and
scan immediately delegate to their sync counterparts like parseSync, so clarify
this behavior in the implementation or docs near those exported functions. If
you keep the shim, add a note that these Promise-returning APIs still block the
event loop and should not be treated as non-blocking; if real async behavior is
needed later, move the work off-thread via a worker or threadpool.

In `@native/test/errors.test.js`:
- Around line 7-15: The negative test around parseSync should explicitly fail if
no exception is thrown, since it currently only checks inside the catch block
and can pass silently when parseSync() stops throwing. Update the test to add an
immediate assert.fail("Expected error") after the parseSync call in the existing
try/catch, using the parseSync test case and the sqlDetails assertions to keep
the error contract covered.

In `@native/test/smoke.mjs`:
- Around line 25-33: The smoke test runner in test/smoke.mjs treats every case
as synchronous, so async tests like parse (async) works and loadModule is a
no-op can resolve or reject after the suite already reports success. Make test()
async, await fn() inside it, and ensure each test(...) call is awaited
(sequentially or via an awaited Promise collection) before the final
process.exit(exitCode), using the existing test() helper and exitCode flow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f4e1e76a-52f6-4cf7-b0ed-d0c4615fa552

📥 Commits

Reviewing files that changed from the base of the PR and between 74ed197 and 5302fd8.

📒 Files selected for processing (30)
  • .github/workflows/build-wasm-no-docker.yaml
  • .github/workflows/build-wasm.yml
  • .github/workflows/ci.yml
  • .github/workflows/native-benchmark.yml
  • .github/workflows/native-build.yml
  • .github/workflows/native-ci.yml
  • .github/workflows/native-release.yml
  • README.md
  • native/.gitignore
  • native/Makefile
  • native/README.md
  • native/benchmark/breakdown.mjs
  • native/benchmark/ci-bench.mjs
  • native/benchmark/compare-allocators.sh
  • native/benchmark/memory.mjs
  • native/benchmark/run-with-jemalloc.sh
  • native/package.json
  • native/platforms.json
  • native/scripts/package-platforms.mjs
  • native/scripts/sync-optional-deps.mjs
  • native/src/addon.cc
  • native/src/index.ts
  • native/test/errors.test.js
  • native/test/fingerprint.test.js
  • native/test/normalize.test.js
  • native/test/parsing.test.js
  • native/test/plpgsql.test.js
  • native/test/scan.test.js
  • native/test/smoke.mjs
  • native/tsconfig.json
💤 Files with no reviewable changes (3)
  • .github/workflows/build-wasm-no-docker.yaml
  • .github/workflows/build-wasm.yml
  • .github/workflows/ci.yml

Comment thread .github/workflows/native-benchmark.yml Outdated
Comment thread .github/workflows/native-build.yml
Comment thread .github/workflows/native-build.yml
Comment thread .github/workflows/native-ci.yml Outdated
Comment thread .github/workflows/native-ci.yml
Comment thread native/README.md
Comment thread native/src/addon.cc
Comment thread native/src/index.ts
Comment thread native/test/errors.test.js
Comment thread native/test/smoke.mjs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
.github/workflows/native-release.yml (1)

106-124: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make the publish flow retry-safe. If one package publishes and a later npm publish or the final git push fails, rerunning the workflow for the same version will hit immutable npm versions and stop on the already-published packages. That leaves the release partially published and forces a manual version bump.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/native-release.yml around lines 106 - 124, Make the native
release publish steps idempotent and retry-safe by changing the publish logic in
the “Publish platform packages” loop and the “Publish main package” step so
reruns skip versions already on npm instead of failing on immutable publishes.
Add a pre-publish check around the existing npm publish calls that detects
whether the current package/version is already published and only publishes when
needed, then keep the final git push unchanged so a rerun after a partial
release can complete cleanly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In @.github/workflows/native-release.yml:
- Around line 106-124: Make the native release publish steps idempotent and
retry-safe by changing the publish logic in the “Publish platform packages” loop
and the “Publish main package” step so reruns skip versions already on npm
instead of failing on immutable publishes. Add a pre-publish check around the
existing npm publish calls that detects whether the current package/version is
already published and only publishes when needed, then keep the final git push
unchanged so a rerun after a partial release can complete cleanly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2098868d-f875-4d80-a08a-e937bab690c1

📥 Commits

Reviewing files that changed from the base of the PR and between 5302fd8 and 0304aa3.

📒 Files selected for processing (1)
  • .github/workflows/native-release.yml

Code correctness:
- addon.cc: escape control chars (<0x20) as \u00XX in EscapeJsonString so
  error payloads stay valid JSON (was emitting raw \b/\f/\x01 -> JSON.parse
  SyntaxError instead of SqlError).
- test/smoke.mjs: make the runner async and await each case (top-level await);
  the two async tests were never awaited, so failures passed silently.
- test/errors.test.js: add assert.fail("Expected error") to the one negative
  test missing it, so it can't pass vacuously.
- benchmark/memory.mjs: exit non-zero when a requested backend fails to load
  instead of silently dropping it.

Security hardening (zizmor):
- Avoid template injection: pass matrix.platform/container and inputs.version/
  tag through `env:` and reference shell vars in run/docker blocks instead of
  inlining ${{ }} expressions.
- Add least-privilege permissions: contents: read on native-build.yml and
  native-ci.yml; move native-benchmark.yml permissions to the job.
- Pin all actions to commit SHAs (checkout v7.0.0, setup-node v6.4.0,
  upload-artifact v7.0.1, download-artifact v8.0.1, github-action-benchmark
  v1.20.7).

Maintainability:
- native-ci.yml: drop the duplicated matrix-generation job; consume the build
  workflow's matrix via a new workflow_call output.
- Makefile: clone libpg_query into a temp dir and mv into place on success so
  an interrupted clone can't poison the cache.
- README: note the jemalloc path varies by distro/arch.

Skipped: the "async API is synchronous" note (by design, matches the WASM API).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
native/README.md (1)

134-137: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The warning note does not fix the broken copy-paste examples.

Supported Linux targets still get x86_64 Debian commands above, so users on arm64 or non-Debian hosts can still follow the README and fail immediately. Please make the example commands themselves architecture-aware, or replace them with an inline lookup step instead of only calling it out in prose.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@native/README.md` around lines 134 - 137, The README examples still hardcode
x86_64 Debian/Ubuntu jemalloc paths, so update the installation commands in
native/README.md to be architecture- and distro-aware instead of only mentioning
the variation in prose. Use the existing jemalloc path guidance to revise the
example command block or replace it with an inline lookup step (for example, via
ldconfig -p) so readers following the commands on arm64 or RHEL/Fedora are
guided to the correct libjemalloc.so.2 path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/native-benchmark.yml:
- Around line 24-28: The checkout step in the workflow is persisting credentials
even though the job already passes an explicit github-token to
github-action-benchmark, so update the actions/checkout usage to disable
credential persistence for this job. Keep the change scoped to the checkout step
in the benchmark workflow and ensure the later build and benchmark steps
continue to work without relying on the checkout token.

In @.github/workflows/native-build.yml:
- Line 24: The checkout step in the workflow still persists credentials in local
git config, which is unnecessary for these read-only build/test jobs. Update
both jobs that use actions/checkout to disable persisted credentials in the
checkout configuration so later steps can only access repository files, and keep
the change aligned with the existing checkout step in native-build workflow.

In @.github/workflows/native-ci.yml:
- Line 39: Disable credential persistence on the actions/checkout step used by
the package-test job in native-ci.yml, since this job only reads the repository
and does not push back to GitHub. Update the checkout configuration to avoid
leaving the token in git config, and keep the change scoped to the package-test
workflow step identified by actions/checkout so repo-controlled install/package
steps still work normally.

---

Duplicate comments:
In `@native/README.md`:
- Around line 134-137: The README examples still hardcode x86_64 Debian/Ubuntu
jemalloc paths, so update the installation commands in native/README.md to be
architecture- and distro-aware instead of only mentioning the variation in
prose. Use the existing jemalloc path guidance to revise the example command
block or replace it with an inline lookup step (for example, via ldconfig -p) so
readers following the commands on arm64 or RHEL/Fedora are guided to the correct
libjemalloc.so.2 path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3e2aa718-bf81-4652-aed1-27bb7b0239f6

📥 Commits

Reviewing files that changed from the base of the PR and between 0304aa3 and 2ba52ec.

📒 Files selected for processing (10)
  • .github/workflows/native-benchmark.yml
  • .github/workflows/native-build.yml
  • .github/workflows/native-ci.yml
  • .github/workflows/native-release.yml
  • native/Makefile
  • native/README.md
  • native/benchmark/memory.mjs
  • native/src/addon.cc
  • native/test/errors.test.js
  • native/test/smoke.mjs

Comment thread .github/workflows/native-benchmark.yml
Comment thread .github/workflows/native-build.yml
Comment thread .github/workflows/native-ci.yml
Ben Asher (benasher44) and others added 2 commits June 30, 2026 09:55
Set persist-credentials: false on actions/checkout in the build, ci, and
benchmark workflows. Those jobs only read repo files and run build/test/pack
steps after checkout, so the GITHUB_TOKEN doesn't need to sit in local git
config (zizmor artipacked). The benchmark action pushes via its explicit
github-token input, not the checkout credentials, so auto-push still works.

native-release.yml is intentionally left as-is: its publish job pushes the
git tag and relies on the persisted checkout token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Published all 6 packages (main + 5 platform binaries) to npm under the
'beta' dist-tag at this version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

2 participants