Skip to content

feat(runtime): raise the baseline to Node 24 LTS and refresh the dependency tree (#326) - #334

Merged
cevheri merged 6 commits into
mainfrom
feat/node-24-baseline
Aug 11, 2026
Merged

feat(runtime): raise the baseline to Node 24 LTS and refresh the dependency tree (#326)#334
cevheri merged 6 commits into
mainfrom
feat/node-24-baseline

Conversation

@cevheri

@cevheri cevheri commented Aug 11, 2026

Copy link
Copy Markdown
Member

Closes #326.

The agent runtime the LibreDB Agent epic depends on needs a modern Node and ESM, so the app and the published package move to Node 24 LTS before any agent code lands. This is the runway change; it contains no agent feature.

The issue contradicts itself, and the measurement says which half wins

Scope bullet 1 asks for engines.node >= 24. Acceptance criterion 1 asks that npx @libredb/studio on Node 20/22 "stops with a clear preflight error naming the required version". The first makes the second impossible, because engines is precisely the mechanism that hides the new release from those runtimes.

Measured rather than assumed - npm install eslint under Node 18 installs 9.39.5 instead of 10.x, and the entire output is added 86 packages in 3s. No warning. npm's picker silently resolves the newest engine-compatible version, which is the mechanism PR #142 documented when it lowered this same floor to fix #130.

So a Node 20/22 user is pinned, not refused. Three things follow:

  • The release runbook now runs npm deprecate on the last pre-floor version. A deprecation notice is the one message npm does print on install, and it targets exactly the pinned population - anyone resolving a supported version never sees it.
  • npx-engine-smoke.yml grows pinned legs (20.9, 22) asserting those users still land on a release that boots. That is the npx @libredb/studio resolves to ancient 0.9.13 for Node 24.0-24.15 users (engines floor too strict) #130 regression class, and it is now under test rather than assumed.
  • docs/DISTRIBUTION.md states the pinning behaviour plainly instead of claiming a refusal that does not happen.

better-sqlite3 13 is what makes Node 26 supported rather than degraded

The first draft of this change documented Node 26 as "works, minus server-side SQLite storage", because the payload's binding is compiled for whichever ABI built it. That turned out to be a stale-dependency problem, not a packaging one.

better-sqlite3 v13.0.0 moved to the N-API: one prebuilt binary per platform, valid across Node majors. Probed in containers, both directions:

Node 24 (ABI 137) Node 26 (ABI 147)
better-sqlite3 12.11.1 works ERR_DLOPEN_FAILED
better-sqlite3 13.0.3 works works

Consequences: the launcher has a floor and no ceiling; the engine-smoke matrix drops the 20.9/22 tiers for 24 + 26, where the node26 leg proves a Node-24-built payload runs its native module on a different major; and bindings / file-uri-to-path leave the payload and the runtime image entirely (they are no longer in the lockfile).

The ABI guard was making a floor-shaped claim

src/lib/storage/providers/sqlite.ts said requires Node.js 24+. A Node 26 user reads that as satisfied while the module still refuses to load. A native binding's constraint is the ABI it was built against, never a version floor. Reworded, with a regression test that fails on floor-shaped wording in either direction.

Dependency refresh

Everything outdated moves within its existing semver range (~50 packages). Two of those bumps were real work rather than a version string:

  • mysql2 3.23 narrowed execute's values parameter from any to a union that excludes undefined. The provider interface every driver implements passes unknown[] and cannot be narrowed here without narrowing it for MongoDB and Redis too, so the 15 call sites go through one documented boundary cast derived from the method signature.
  • happy-dom 20.11 now validates dispatchEvent's argument against its own Event class. tests/setup-dom.ts only copied DOM globals that were missing, and Bun ships its own CustomEvent, so events were constructed in one realm and dispatched into another. Latent since the setup was written; 20.11 is just the first version that checks. Event constructors now always come from the window.

better-sqlite3 12 -> 13 additionally required trustedDependencies: bun synthesizes a node-gyp rebuild for any package shipping a binding.gyp and ignores v13's gypfile: false, so bun install died on a toolchain the package does not need. The field is now an explicit allowlist of the three packages that already ran scripts before it existed - which is also a supply-chain improvement, since nothing else can execute code at install time.

knip 6.32 (which ^6.17.1 already resolved to) correctly reports 39 type re-exports in internal barrels that nothing imports. They are not API: the package's public surface is src/exports/*, which re-exports from src/lib/types directly and never passes through those barrels. Removed.

The image being smoke tested was not the image CI publishes

.dockerignore did not exclude the sibling product trees, so a local docker build copied whatever the developer happened to have built. A Rust target/ directory alone put 2.3 GB of desktop-shell objects inside the production image: 5.96 GB, against ~500-550 MB for the published ones. After excluding those trees: 539 MB. CI checkouts are clean so the published image was never affected, but local verification was measuring something else.

Verification

Six gates plus build:lib and attw green; line coverage held at 30830/30830 (100%).

Driven in Chrome with Playwright, separately against bun dev and against the built image: login, schema introspection, and the editor -> API -> SQLite provider -> results grid path. The image ran SELECT dept_name, COUNT(*) ... JOIN ... GROUP BY ... and returned correct rows with zero console errors or warnings.

Deliberately not in this PR

  • Major bumps, each of which deserves its own migration: recharts 2->3, @tanstack/react-table 8->9, react-resizable-panels 3->4, react-day-picker 9->10, ioredis 5->6, oracledb 6->7, framer-motion 12->13, lucide-react 0.562->1.31, eslint 9->10, @types/node 25->26.
  • TypeScript 7, probed and recorded as docs/BACKLOG.md A7: it compiles this project with zero errors in 1.8s against 7.7s for 6.0.3, but typescript-eslint@8.67.0 declares peerDependencies.typescript: ">=4.8.4 <6.1.0", and TS 7 no longer ships the in-process compiler API that typescript-eslint, eslint-config-next and tsup's declaration build all consume. A one-line bump the day that peer range opens.
  • The Docker base pin stays at node:24.16.0. Bumping the patch would reduce CVEs but invalidates the dated measurement recorded in security-scan.yml (2026-08-09: 4 critical, 18 high), which should be re-measured with trivy in its own change.
  • docs/BACKLOG.md A6 records that Druid's hand-written bigint serializer exists only because JSON.rawJSON was unavailable below Node 22.2 - a constraint this PR removes.

…ndency tree (#326)

The agent runtime the LibreDB Agent epic depends on needs a modern Node and
ESM, so the app and the published package move to Node 24 LTS before any agent
code lands. This is the runway change, not an agent feature.

engines.node goes to >=24.0.0 and the launcher preflight follows it. Note what
that field does NOT do: it produces no error for a bare spec. npm's version
picker silently resolves the newest ENGINE-COMPATIBLE release instead, which
was measured, not assumed - `npm install eslint` under Node 18 installs 9.39.5
with no warning at all. So a Node 20/22 user running `npx @libredb/studio` is
pinned to the last pre-floor release rather than refused, and the acceptance
criterion asking for "a clear preflight error" cannot be met through engines.
The release runbook now deprecates the pinned version with a pointer, because a
deprecation notice is the one message npm does print on install, and
npx-engine-smoke.yml grows `pinned` legs that assert those users still land on
a release that BOOTS (the #130 regression class).

better-sqlite3 12 -> 13, which is what makes Node 26 fully supported rather
than degraded. v13 moved to the N-API: one prebuilt binary per platform, valid
across Node majors. Probed both directions in containers - v12's binding
(NODE_MODULE_VERSION 137) throws ERR_DLOPEN_FAILED on Node 26 (147), v13 runs
clean on 24 and 26 from the same install. Consequences: the launcher has a
floor and no ceiling, the engine-smoke matrix drops the 20.9/22 tiers for
24 + 26 where the node26 leg proves a Node-24-built payload runs its native
module on another major, and `bindings` / `file-uri-to-path` leave the payload
and the image entirely.

The storage ABI guard said "requires Node.js 24+", which a Node 26 user reads
as satisfied while the module still refuses to load. A native binding's
constraint is the ABI it was built against, never a version floor - reworded,
with a regression test that fails on floor-shaped wording.

trustedDependencies is now an explicit allowlist. bun synthesizes a `node-gyp
rebuild` for any package shipping a binding.gyp and ignores better-sqlite3 13's
`gypfile: false`, so installs died on a toolchain the package does not need.
The three named are exactly what ran scripts before the field existed.

Everything else outdated moves within its existing range (~50 packages). Two
of those bumps were real work:

- mysql2 3.23 narrowed `execute`'s values parameter from `any` to a union that
  excludes undefined; the shared provider interface passes `unknown[]`, so the
  15 call sites go through one documented boundary cast.
- happy-dom 20.11 now validates dispatchEvent's argument against its own Event
  class. tests/setup-dom.ts only copied DOM globals that were missing, and Bun
  ships its own CustomEvent, so events were built in one realm and dispatched
  into another. Latent since the setup was written; the event constructors now
  always come from the window.

knip 6.32 (which `^6.17.1` already resolved to) correctly reports 39 type
re-exports in internal barrels that nothing imports. They are not API - the
package's public surface is src/exports/*, which re-exports from src/lib/types
directly and never passes through those barrels - so they are removed.

.dockerignore did not exclude the sibling product trees, so a local
`docker build` copied whatever the developer had built: a Rust target/ dir put
2.3 GB of desktop-shell objects into the production image. 5.96 GB -> 539 MB,
back in line with the published images, and a locally built image is now
representative of what CI publishes.

Verified beyond the six gates: 100% line coverage held at 30830 lines, and the
full editor -> API -> SQLite provider -> results grid path was driven in Chrome
against both `bun dev` and the built image, the latter with zero console
errors. TypeScript 7 was probed too - it compiles this project with no errors
in 1.8s against 7.7s - but typescript-eslint's peer range still excludes it;
recorded as BACKLOG A7 with A6 (Druid's JSON.rawJSON workaround, now
unnecessary).
@cevheri
cevheri requested a balanced review from Copilot August 11, 2026 00:02
Comment thread src/lib/db/providers/sql/mysql.ts Dismissed
…326)

A7 put the onus on typescript-eslint's peer range. Measuring the published
packages shows the blocker is a level up: typescript@7.0.2 and today's
7.1.0-dev nightly both export only `version` and `versionMajorMinor` from
`require("typescript")` - `createProgram` and `Extension` are undefined, so
there is no API for typescript-eslint, eslint-config-next or tsup to port to.

Records the real tracking issue (#10940, open, labelled 'blocked by external
API'), notes that #12518 reading as 'not planned' is just how close-as-duplicate
renders, and captures Microsoft's documented 6.0/7.0 side-by-side setup as the
interim option if the 4x typecheck gain is wanted before the API lands.
Trivy flagged lodash 4.17.21 (CVE-2026-4800, arbitrary code execution via
untrusted input in template imports, HIGH). The alert reads as introduced by
this pull request only because bun.lock moved - main carries the identical
version and hash - but it is fixable here and cheap to take.

lodash reaches the tree solely through recharts, which asks for ^4.17.21, so
the patched 4.18.1 is already inside the parent's range and nothing in this
repository imports lodash directly. An override forces the resolution forward
without claiming a direct dependency the code does not have; `bun update
lodash` would have added one, which knip would then correctly call unused.

Remove the entry once recharts raises its own floor.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Raises the runtime baseline to Node 24, modernizes dependencies, and validates Node 26 compatibility without introducing agent functionality.

Changes:

  • Enforces Node 24+ and updates runtime documentation and smoke tests.
  • Migrates to better-sqlite3 13 N-API prebuilds.
  • Refreshes dependencies and removes unused internal type exports.

Reviewed changes

Copilot reviewed 39 out of 42 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
.dockerignore Excludes non-image build artifacts.
.github/workflows/ci.yml Tests Node 24 and 26 payloads.
.github/workflows/npx-engine-smoke.yml Validates supported and pinned npm resolutions.
Dockerfile Updates better-sqlite3 packaging.
README.md Documents the Node 24 floor.
README_zh.md Updates Chinese runtime guidance.
README_ja.md Updates Japanese runtime guidance.
package.json Raises engines and refreshes dependencies.
bun.lock Resolves the refreshed dependency graph.
bin/studio.js Removes obsolete degraded-runtime warnings.
bin/lib/launcher-utils.mjs Enforces the Node 24 minimum.
scripts/build-standalone-payload.sh Packages self-contained better-sqlite3 v13.
scripts/engine-smoke.sh Validates Node 24/26 runtime behavior.
docs/BACKLOG.md Records deferred runtime follow-ups.
docs/DESKTOP_WRAPPER_SPIKE.md Updates native-module packaging notes.
docs/DISTRIBUTION.md Documents runtime support and npm pinning.
docs/providers/druid.md Updates JSON serialization rationale.
docs/providers/sqlite.md Documents the Node 24 SQLite floor.
src/components/studio/index.ts Removes an unused type re-export.
src/lib/db/index.ts Trims unused barrel exports.
src/lib/db/providers/sql/druid/http-transport.ts Updates runtime rationale.
src/lib/db/providers/sql/mysql.ts Adapts parameters to mysql2 typings.
src/lib/db/providers/sql/sqlite-driver.ts Updates runtime guidance.
src/lib/db/types.ts Removes unused type re-exports.
src/lib/explain/index.ts Trims unused explain exports.
src/lib/llm/index.ts Removes unused type mirroring.
src/lib/seed/index.ts Trims unused seed exports.
src/lib/sql/index.ts Removes unused SQL type exports.
src/lib/storage/index.ts Trims unused storage exports.
src/lib/storage/providers/sqlite.ts Rewords native-module errors.
tests/setup-dom.ts Aligns event constructors with happy-dom.
tests/components/QuerySafetyDialog.test.tsx Reformats parameterized tests.
tests/integration/db/druid-provider.test.ts Reformats maintenance tests.
tests/security/agent-statement-boundary.test.ts Reformats SQLite boundary tests.
tests/unit/lib/auth-bootstrap.test.ts Reformats bootstrap tests.
tests/unit/db/clickhouse/introspect.test.ts Reformats introspection tests.
tests/unit/db/clickhouse/transport.test.ts Reformats transport tests.
tests/unit/db/druid/introspect.test.ts Reformats introspection tests.
tests/unit/db/druid/transport.test.ts Reformats transport tests.
tests/unit/launcher-utils.test.ts Tests the Node 24 floor.
tests/unit/lib/storage/providers/sqlite.test.ts Tests ABI-neutral error wording.
tests/unit/sql/grammar.test.ts Reformats grammar tests.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/lib/db/providers/sql/mysql.ts Outdated
Comment thread docs/BACKLOG.md Outdated
Comment thread docs/DISTRIBUTION.md
Comment thread src/lib/storage/providers/sqlite.ts
Comment thread Dockerfile Outdated
Comment thread docs/DISTRIBUTION.md
…seline (#326)

All six were valid; each was checked against the source rather than accepted.

- mysql.ts: the boundary comment claimed an `undefined` bind value 'has always
  been sent as NULL'. It has not - mysql2 throws 'Bind parameters must not
  contain undefined. To pass SQL NULL specify JS null' from
  lib/base/connection.js. Corrected, because the wrong version invites a future
  caller to pass a value the driver rejects.
- BACKLOG A6 named src/lib/db/providers/sql/druid.ts, which does not exist; the
  serializer lives in druid/http-transport.ts. A follow-up note that points at
  the wrong file is worse than no note.
- Dockerfile: the libc comment survived the v13 upgrade and was no longer true.
  lib/binding.js picks the prebuild in the RUNNING process (process.platform,
  process.arch, and musl via process.report), so neither the ABI nor the libc of
  the installing stage constrains the stage that requires it.
- storage/providers/sqlite.ts: the file header still framed the guard as an
  older-Node ABI failure while the error message it guards now describes the
  N-API reality - one module documenting two mutually exclusive behaviours.
- DISTRIBUTION.md: the Homebrew section still gave the ABI as the reason for the
  node@24 dependency. The pin stays, but as conservatism (keeping users on the
  major CI exercises), not necessity.
- channels.yaml: the issue's runtime-ownership sweep was genuinely missing.
  Every channel now carries a required `runtime` field, validated against
  user_supplied | channel_supplied, so a new channel must state who owns the
  Node rather than omit it.

The sweep also settled the question the issue guessed at: only 2 of 27 channels
are user_supplied (npm and the standalone tarballs). deb/rpm are NOT among them
- packaging/linux/fetch-node.sh installs a private bin/node into the payload -
so the floor is invisible on 25 channels. That is recorded in the manifest and
in DISTRIBUTION.md as a stated field, precisely because it is not derivable from
`kind`.
…deploy walks the AppDir (#326)

The Flatpak Smoke AppImage jobs failed with a bare 'failed to run linuxdeploy'.
Caused by this branch, and by the same mechanism the script already documents
one screen above for sharp.

better-sqlite3 12 shipped a single binding compiled for the build host. v13 is
N-API, so the one package carries a prebuild for every platform: Mach-O for
darwin, PE for win32, and - the part that matters - Linux ELFs for the other
arch and for musl. linuxdeploy walks every ELF in the AppDir and treats an
unresolvable dependency as fatal, and `readelf -d` on
prebuilds/linuxmusl-x64.node shows NEEDED libc.musl-x86_64.so.1, which is
exactly the dependency named in the existing @img/*musl* prune comment.

Same fix, same place, same rule the sharp prune already states: the release
tarball keeps every prebuild, only this bundle drops what it cannot load. Fails
loudly if the prebuild for the build arch is missing, so a future packaging
change cannot silently ship an AppImage without SQLite storage.

Isolated rather than assumed: the workflow is path-filtered on
scripts/build-standalone-payload.sh, so this PR was its first run since
2026-07-31, which made 'pre-existing breakage' a live alternative until the
musl ELF was found in the staged tree.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 43 out of 46 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

distribution/channels.yaml:80

  • This row cannot truthfully use either runtime-owner value: its own name says it covers standalone tarballs (user-supplied Node) as well as deb/rpm and snap assets (which bundle Node). Classifying the whole GitHub Release channel as user_supplied makes the new channel-impact metadata wrong for most asset types. Split this inventory row by runtime ownership or allow an explicit mixed classification.
    runtime: user_supplied

docs/DISTRIBUTION.md:334

  • This does not make the bare-npx pin visible to the affected Node 20/22 users. The linked #142 validation records that npm 10's npx flow does not surface deprecation notices, and those runtimes ship that flow; the new smoke legs likewise only assert that the pinned release boots. Consequently the PR still leaves the issue's npx population silently pinned while documenting the deprecation as the notification mechanism. Provide and test a mechanism that reaches bare-npx users, or document that this acceptance criterion remains unmet.
> To make the pin visible, the release runbook `npm deprecate`s the last pre-floor version with a
> pointer to the Node 24 requirement - a deprecation notice is the one message npm *does* print on
> install. A user who pins the new version explicitly (`npx @libredb/studio@<version>`) gets the
> launcher's own preflight refusal naming the required runtime.

Comment thread scripts/build-standalone-payload.sh Outdated
The payload script's recovery path for an unloadable native binding became a
no-op with the v13 upgrade, and the flag I added to 'fix' it does not exist.

Measured, not inferred. With prebuilds/ removed from a clean install:

  $ npm rebuild better-sqlite3 --build-from-source --foreground-scripts
  npm warn Unknown cli config "--build-from-source". This will stop working [...]
  rebuilt dependencies successfully
  $ ls node_modules/better-sqlite3/build/Release/
  ls: cannot access [...]: No such file or directory

Two independent faults. --build-from-source was a prebuild-install flag, and
v13 dropped prebuild-install, so npm does not recognise it. And v13 declares no
install lifecycle with gypfile: false, which is exactly the switch that stops
npm synthesising node-gyp from binding.gyp - so the rebuild queues nothing and
still reports success. Under v12 this worked, because the package declared
'install: prebuild-install || node-gyp rebuild'. The upgrade silently removed a
working recovery path.

CI never caught it because the block sits behind a probe that passes on every
platform we build on: v13 prebuilds all five targets this script accepts. The
cost was diagnostic - on a genuinely broken tree it printed 'building from
source', did nothing, and died on the next line with a bare Node stack.

Automating a real source build was the other option and is the wrong trade: it
would put python3 and a C++ toolchain on the macOS and Windows release runners
to recover a case that cannot arise on a complete install. The guard now fails
with the diagnosis and hands over the one-off node-gyp command, matching the
prebuild guard added to build-desktop-appimage.sh in 4f6dffc.
@cevheri
cevheri merged commit 7d497ea into main Aug 11, 2026
21 checks passed
@cevheri
cevheri deleted the feat/node-24-baseline branch August 11, 2026 00:56
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants