Files
SnowLuma/tools/fetch-sqlite-prebuilts.mjs
T
DrSmoothl 4e51b4bd7d build(sqlite): wrap better-sqlite3 with @snowluma/sqlite + vendor prebuilts
Replaces "runtime npm dependency that gets `npm install`-ed into
dist/" with "TS wrapper that loads the native `.node` from
`dist/native/` like every other native binding in the project".

Why the change
--------------
The previous approach (better-sqlite3 declared in
packages/runtime/package.json, materialized by `npm install
--omit=dev` in dist/) worked but produced an artifact that needed
runtime npm install — a step the rest of SnowLuma's native bindings
don't need (websocket / snowluma-hook / ffmpeg are all standalone
`.node` files copied into `dist/native/` by vite-plugin-cp). The
asymmetry was friction: dev mode required `pnpm dist:install`,
build artifacts carried a `dist/node_modules/` tree the other
modules didn't need, and Docker images had to special-case the
glibc-vs-musl ABI lookup that prebuild-install runs.

The new shape:
  - `@snowluma/sqlite` (packages/sqlite/) — TS wrapper. Has
    `better-sqlite3` as a devDep (for the JS-layer types + the
    actual JS wrapper code that vite inlines). At runtime, calls
    `createRequire().require()` against
    `<dist|repo>/native/better-sqlite3-v<abi>-<platform>-<arch>.node`
    and passes the resolved addon to `new Database(path, {
    nativeBinding })`.
  - `packages/runtime/native/better-sqlite3-v<abi>-<triplet>.node`
    — prebuilt binaries vendored into the repo. Two ABIs ship side-
    by-side (v127 = Node 22 LTS used by CI, v137 = Node 24 used by
    dev boxes) so `process.versions.modules` picks the matching one
    at load time.
  - `tools/fetch-sqlite-prebuilts.mjs` — fetches all 5 platforms ×
    N ABIs from WiseLibs's GitHub releases. Idempotent: existing
    `.node` files skip. Accepts extra ABI args:
    `node tools/fetch-sqlite-prebuilts.mjs 12.10.0 v127 v137 v115`.

What was unwound
----------------
- packages/runtime/package.json — dependencies removed (runtime
  artifact is now self-contained, no `npm install` step needed).
- root package.json — `dist:install` script removed (no node_modules
  to install).
- .github/workflows/dev-build.yml — `Bundle runtime dependencies`
  step removed.
- .github/workflows/release.yml — kept the `Bundle runtime
  dependencies` step (was a no-op already, but harmless safety net
  in case a future runtime dep shows up).
- packages/core/vite.config.ts — `better-sqlite3` no longer
  external; vite inlines the JS wrapper (+~33 KB to bundle, ~2.84
  MB → 2.87 MB total).

Naming
------
Mirrors the existing `websocket-${triplet}.node` convention (flat
dir under `runtime/native/`, dash-separated `<platform>-<arch>`).
Adds a `v<abi>` segment because better-sqlite3 is NOT N-API — one
binary per Node ABI. Naming:

  better-sqlite3-v127-darwin-arm64.node    Node 22 / macOS arm
  better-sqlite3-v127-darwin-x64.node      Node 22 / macOS intel
  better-sqlite3-v127-linux-arm64.node     Node 22 / Linux arm
  better-sqlite3-v127-linux-x64.node       Node 22 / Linux x64
  better-sqlite3-v127-win32-x64.node       Node 22 / Windows x64
  better-sqlite3-v137-... (5)              Node 24 / same 5 hosts

Total: 10 binaries × ~1.9 MB = ~19 MB committed. Aligned with the
existing native-vendoring footprint (websocket ~2 MB × 5, snowluma-
hook ~10 MB × Windows + Linux, ffmpeg ~10-30 MB × 4).

What changes for consumers
--------------------------
Source-side: nothing. `import Database, { type Database, type
Statement } from '@snowluma/sqlite'` is a drop-in for the previous
`import Database from 'better-sqlite3'`. The wrapper re-exports the
same types and the `Database` callable factory has the same
overload set.

Runtime: `node` finds the binding at
`<dist|repo>/native/better-sqlite3-v${process.versions.modules}-
${process.platform}-${process.arch}.node`. If the ABI doesn't
match a vendored binary, the wrapper throws a clear error pointing
at `tools/fetch-sqlite-prebuilts.mjs` with the missing ABI/triplet.

Bumped better-sqlite3 from 11.10.0 → 12.10.0 because v11 only had
prebuilt up to Node ABI v127 (no v137 = no Node 24). v12.10.0
ships both. Both versions are API-compat (the breaking changes
between v11 and v12 were either internal or in error-handling
paths SnowLuma doesn't exercise).

Verification
------------
- pnpm -r typecheck (12 projects): clean
- @snowluma/core test: 302/302
- @snowluma/onebot test: 250/250 (552/552 combined, unchanged from
  the perf-series baseline)
- Bench numbers preserved — wrapper indirection is sub-μs:
  storeMeta ~11 μs, storeEvent ~17 μs, findMeta hit ~1.1 μs (all
  within noise of the post-RoadMap-#5 measurements).
- Vite build clean, bundle = 2.87 MB / 230 KB gzip
- `dist/index.mjs` no longer contains a `node:sqlite` import (it
  did before — media-store hadn't migrated; now neither it nor
  message-store reach into the Node builtin).

Bonus side-fix: `packages/onebot/src/media-store.ts` also moved
off `node:sqlite` (caught by the post-build bundle grep — the
file was using `DatabaseSync` + `StatementSync` from the builtin
even though it had already been written to cache its prepared
statements). Its hot paths now go through the same wrapper.
2026-05-21 13:49:19 +08:00

122 lines
5.2 KiB
JavaScript

#!/usr/bin/env node
// Downloads better-sqlite3 prebuilt native bindings for all SnowLuma
// CI matrix targets (win32-x64, linux-x64, linux-arm64) plus the
// developer host (darwin-arm64 / darwin-x64) and vendors them into
// `packages/runtime/native/better-sqlite3-<platform>-<arch>.node`.
//
// vite-plugin-cp picks the matching binary for `SNOWLUMA_TARGET`
// during `pnpm build` and copies it into `dist/native/`, where
// `@snowluma/sqlite/index.ts` finds it at runtime. Naming + layout
// mirror the existing `websocket-<platform>-<arch>.node` convention
// (flat dir, dash-separated triplet) so SnowLuma's existing
// per-target packaging step doesn't need a new code path.
//
// Usage:
// node tools/fetch-sqlite-prebuilts.mjs # default version
// node tools/fetch-sqlite-prebuilts.mjs 11.10.0 # explicit version
//
// Re-run when bumping the better-sqlite3 pin in
// `packages/sqlite/package.json`. The script is idempotent —
// existing binaries with the same SHA256 are left alone.
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(__dirname, '..');
const nativeRoot = join(repoRoot, 'packages', 'runtime', 'native');
// Default NODE_MODULE_VERSIONs to vendor. v127 = Node 22 LTS (matches
// the CI matrix + `engines.node: ">=22"`). v137 = Node 24 (covers dev
// boxes already on the newer release). Pass extra versions positionally
// after the better-sqlite3 version to add more:
// node tools/fetch-sqlite-prebuilts.mjs 11.10.0 v127 v137 v115
const DEFAULT_NODE_ABIS = ['v127', 'v137'];
// The targets vendored into the repo. linuxmusl-* variants are
// available too (for Alpine-based Docker images) but SnowLuma's
// existing native vendoring (websocket / ffmpeg) only ships glibc
// Linux, so we follow that lead. Add musl entries here if/when a
// downstream Alpine consumer asks.
const TARGETS = [
{ triplet: 'darwin-arm64', prebuildName: 'darwin-arm64' },
{ triplet: 'darwin-x64', prebuildName: 'darwin-x64' },
{ triplet: 'linux-arm64', prebuildName: 'linux-arm64' },
{ triplet: 'linux-x64', prebuildName: 'linux-x64' },
{ triplet: 'win32-x64', prebuildName: 'win32-x64' },
];
async function main() {
const version = process.argv[2] ?? readVersionFromPin();
const abis = process.argv.slice(3).filter(a => a.startsWith('v'));
const targetAbis = abis.length > 0 ? abis : DEFAULT_NODE_ABIS;
console.log(`fetching better-sqlite3 v${version} prebuilds (ABIs: ${targetAbis.join(', ')})`);
mkdirSync(nativeRoot, { recursive: true });
for (const abi of targetAbis) {
for (const target of TARGETS) {
const destBin = join(nativeRoot, `better-sqlite3-${abi}-${target.triplet}.node`);
if (existsSync(destBin)) {
console.log(` ✓ ${abi} ${target.triplet} (already vendored, skipping)`);
continue;
}
const url = `https://github.com/WiseLibs/better-sqlite3/releases/download/v${version}/better-sqlite3-v${version}-node-${abi}-${target.prebuildName}.tar.gz`;
console.log(` ↓ ${abi} ${target.triplet}: ${url}`);
const tarPath = join(tmpdir(), `better-sqlite3-${version}-${abi}-${target.triplet}.tar.gz`);
await downloadTo(url, tarPath);
extractBinding(tarPath, destBin);
console.log(` → ${destBin}`);
}
}
console.log('done.');
}
function readVersionFromPin() {
const pkg = JSON.parse(readFileSync(join(repoRoot, 'packages', 'sqlite', 'package.json'), 'utf8'));
const raw = pkg.devDependencies?.['better-sqlite3'];
if (!raw) {
throw new Error('cannot find better-sqlite3 pin in packages/sqlite/package.json');
}
// strip the `^` / `~` / `>=` etc prefix.
return raw.replace(/^[\^~>=<]+/, '');
}
async function downloadTo(url, dest) {
// curl is in every CI image SnowLuma cares about (and on every
// dev mac/linux), so we just shell out — keeps this script free
// of npm dependencies, which matters because pnpm install hasn't
// necessarily run yet when someone clones the repo fresh.
const result = spawnSync('curl', ['-fsSL', '--retry', '3', '-o', dest, url], { stdio: 'inherit' });
if (result.status !== 0) {
throw new Error(`curl failed for ${url} (exit ${result.status})`);
}
}
function extractBinding(tarPath, destBin) {
// The prebuild archive is `build/Release/better_sqlite3.node`
// wrapped in the standard better-sqlite3 layout. We capture tar's
// stdout as a buffer (spawnSync's `stdio: 'pipe'` + `encoding: null`)
// and write that buffer to the destination — simpler than wiring
// a Node WriteStream which spawnSync's stdio doesn't accept.
const result = spawnSync(
'tar',
['-xzOf', tarPath, '--strip-components=2', 'build/Release/better_sqlite3.node'],
{ stdio: ['ignore', 'pipe', 'inherit'], encoding: null, maxBuffer: 64 * 1024 * 1024 },
);
if (result.status !== 0) {
throw new Error(`tar failed for ${tarPath} (exit ${result.status})`);
}
writeFileSync(destBin, result.stdout);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});