diff --git a/apps/desktop/scripts/install-sqlite-bindings.cjs b/apps/desktop/scripts/install-sqlite-bindings.cjs index ecd2e68..dcefee6 100644 --- a/apps/desktop/scripts/install-sqlite-bindings.cjs +++ b/apps/desktop/scripts/install-sqlite-bindings.cjs @@ -15,7 +15,7 @@ * `nativeBinding` constructor option, depending on whether process.versions.electron * is defined. * - * Idempotent — skips downloads when both stashed binaries already match the + * Idempotent - skips downloads when both stashed binaries already match the * recorded versions in install-sqlite-bindings.lock.json. Safe to re-run on * every install. * @@ -51,28 +51,77 @@ function resolveElectronVersion() { } } -function downloadPrebuild({ pkgDir, runtime, target, arch, platform, dest }) { - const prebuildBin = path.join(pkgDir, 'node_modules', '.bin', 'prebuild-install'); - if (!fs.existsSync(prebuildBin)) { +function resolvePrebuildInstallEntrypoint(pkgDir) { + try { + return require.resolve('prebuild-install/bin.js', { paths: [pkgDir] }); + } catch { throw new Error( - `prebuild-install not found at ${prebuildBin} — better-sqlite3 install layout changed?`, + `prebuild-install/bin.js could not be resolved from ${pkgDir} - better-sqlite3 install layout changed?`, + ); + } +} + +function runPrebuildInstall({ pkgDir, runtime, target, arch, platform }) { + const prebuildEntrypoint = resolvePrebuildInstallEntrypoint(pkgDir); + try { + const output = execFileSync( + process.execPath, + [ + prebuildEntrypoint, + `--runtime=${runtime}`, + `--target=${target}`, + `--arch=${arch}`, + `--platform=${platform}`, + ], + { cwd: pkgDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, ); + if (output) process.stdout.write(output); + } catch (error) { + if (error && typeof error === 'object') { + if (typeof error.stdout === 'string' && error.stdout.length > 0) + process.stdout.write(error.stdout); + if (typeof error.stderr === 'string' && error.stderr.length > 0) + process.stderr.write(error.stderr); + } + throw error; } +} + +function isMissingPrebuild(error) { + const stdout = + error && typeof error === 'object' && 'stdout' in error && typeof error.stdout === 'string' + ? error.stdout + : ''; + const stderr = + error && typeof error === 'object' && 'stderr' in error && typeof error.stderr === 'string' + ? error.stderr + : ''; + const message = + error instanceof Error ? error.message : typeof error === 'string' ? error : String(error); + return /No prebuilt binaries found/i.test(`${stdout}\n${stderr}\n${message}`); +} + +function downloadPrebuild({ pkgDir, runtime, target, arch, platform, dest, optional }) { const defaultBinary = path.join(pkgDir, 'build', 'Release', 'better_sqlite3.node'); // Move out of the way so prebuild-install doesn't short-circuit. if (fs.existsSync(defaultBinary)) fs.rmSync(defaultBinary); - execFileSync( - prebuildBin, - [`--runtime=${runtime}`, `--target=${target}`, `--arch=${arch}`, `--platform=${platform}`], - { cwd: pkgDir, stdio: 'inherit' }, - ); + try { + runPrebuildInstall({ pkgDir, runtime, target, arch, platform }); + } catch (error) { + if (optional && isMissingPrebuild(error)) { + log(`no published prebuild for ${runtime}@${target} (${platform}-${arch}); skipping`); + return false; + } + throw error; + } if (!fs.existsSync(defaultBinary)) { throw new Error(`prebuild-install for ${runtime}@${target} did not produce ${defaultBinary}`); } fs.copyFileSync(defaultBinary, dest); fs.rmSync(defaultBinary); + return true; } function main() { @@ -104,6 +153,8 @@ function main() { platform, nodeVersion, electronVersion, + hasNodeBinary: fs.existsSync(nodeBinary), + hasElectronBinary: fs.existsSync(electronBinary), }; const upToDate = @@ -113,47 +164,65 @@ function main() { lock.platform === platform && lock.nodeVersion === nodeVersion && lock.electronVersion === electronVersion && - fs.existsSync(nodeBinary) && - (electronVersion === null || fs.existsSync(electronBinary)); + lock.hasNodeBinary === targetLock.hasNodeBinary && + lock.hasElectronBinary === targetLock.hasElectronBinary && + (!targetLock.hasNodeBinary || fs.existsSync(nodeBinary)) && + (!targetLock.hasElectronBinary || fs.existsSync(electronBinary)); if (upToDate) { log( - `up-to-date (node=${nodeVersion}, electron=${electronVersion ?? 'skipped'}, ${platform}-${arch}) — skipping`, + `up-to-date (node=${nodeVersion}, electron=${electronVersion ?? 'skipped'}, ${platform}-${arch}) - skipping`, ); return; } log(`downloading Node prebuild (node=${nodeVersion}, ${platform}-${arch})`); - downloadPrebuild({ + const hasNodeBinary = downloadPrebuild({ pkgDir, runtime: 'node', target: nodeVersion, arch, platform, dest: nodeBinary, + optional: true, }); + let hasElectronBinary = false; if (electronVersion === null) { log('electron not installed; skipping Electron native binding (fine for prod-only installs)'); } else { log(`downloading Electron prebuild (electron=${electronVersion}, ${platform}-${arch})`); - downloadPrebuild({ + hasElectronBinary = downloadPrebuild({ pkgDir, runtime: 'electron', target: electronVersion, arch, platform, dest: electronBinary, + optional: false, }); } + if (!hasNodeBinary && !hasElectronBinary) { + throw new Error('Failed to stage any better-sqlite3 native bindings'); + } + // Leave a default copy in place so any consumer that doesn't pass nativeBinding - // (e.g. ad-hoc node REPL inside this monorepo) still gets a working module - // matching the active runtime. + // (e.g. ad-hoc node REPL inside this monorepo) still gets a working module. + // Prefer the Node binary when available; otherwise fall back to the Electron + // binary so the desktop app still boots on machines where upstream has not + // published a Node prebuild for the active version yet. const defaultBinary = path.join(releaseDir, 'better_sqlite3.node'); - fs.copyFileSync(nodeBinary, defaultBinary); + if (hasNodeBinary) { + fs.copyFileSync(nodeBinary, defaultBinary); + } else if (hasElectronBinary) { + fs.copyFileSync(electronBinary, defaultBinary); + } - fs.writeFileSync(lockPath, `${JSON.stringify(targetLock, null, 2)}\n`); + fs.writeFileSync( + lockPath, + `${JSON.stringify({ ...targetLock, hasNodeBinary, hasElectronBinary }, null, 2)}\n`, + ); log('done'); } diff --git a/apps/desktop/src/main/snapshots-db.binding.test.ts b/apps/desktop/src/main/snapshots-db.binding.test.ts new file mode 100644 index 0000000..6b44fb1 --- /dev/null +++ b/apps/desktop/src/main/snapshots-db.binding.test.ts @@ -0,0 +1,49 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { resolveNativeBindingPath } from './snapshots-db'; + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeReleaseDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codesign-sqlite-binding-')); + tempDirs.push(dir); + return dir; +} + +describe('resolveNativeBindingPath', () => { + it('prefers the Electron-specific binding when present', () => { + const releaseDir = makeReleaseDir(); + const electronBinding = path.join(releaseDir, 'better_sqlite3.node-electron.node'); + const defaultBinding = path.join(releaseDir, 'better_sqlite3.node'); + fs.writeFileSync(electronBinding, ''); + fs.writeFileSync(defaultBinding, ''); + + expect(resolveNativeBindingPath(releaseDir, true)).toBe(electronBinding); + }); + + it('falls back to the default binding when the runtime-specific one is missing', () => { + const releaseDir = makeReleaseDir(); + const defaultBinding = path.join(releaseDir, 'better_sqlite3.node'); + fs.writeFileSync(defaultBinding, ''); + + expect(resolveNativeBindingPath(releaseDir, true)).toBe(defaultBinding); + }); + + it('keeps the Node-specific path when no Node binding was staged', () => { + const releaseDir = makeReleaseDir(); + const defaultBinding = path.join(releaseDir, 'better_sqlite3.node'); + const nodeBinding = path.join(releaseDir, 'better_sqlite3.node-node.node'); + fs.writeFileSync(defaultBinding, ''); + + expect(resolveNativeBindingPath(releaseDir, false)).toBe(nodeBinding); + }); +}); diff --git a/apps/desktop/src/main/snapshots-db.ts b/apps/desktop/src/main/snapshots-db.ts index f0e794e..039c135 100644 --- a/apps/desktop/src/main/snapshots-db.ts +++ b/apps/desktop/src/main/snapshots-db.ts @@ -8,6 +8,7 @@ * Call initInMemoryDb() in tests to get an isolated in-memory instance. */ +import fs from 'node:fs'; import { createRequire } from 'node:module'; import path from 'node:path'; import type { @@ -34,13 +35,22 @@ let singleton: Database | null = null; * so that one `pnpm install` covers both runtimes without * an electron-rebuild step that toggles the single default binary. */ +export function resolveNativeBindingPath( + releaseDir: string, + isElectron = typeof process.versions.electron === 'string', +): string { + const runtimeSpecific = path.join( + releaseDir, + isElectron ? 'better_sqlite3.node-electron.node' : 'better_sqlite3.node-node.node', + ); + if (fs.existsSync(runtimeSpecific)) return runtimeSpecific; + if (isElectron) return path.join(releaseDir, 'better_sqlite3.node'); + return runtimeSpecific; +} + function resolveNativeBinding(): string { - const isElectron = typeof process.versions.electron === 'string'; - const filename = isElectron - ? 'better_sqlite3.node-electron.node' - : 'better_sqlite3.node-node.node'; const pkgJson = require.resolve('better-sqlite3/package.json'); - return path.join(path.dirname(pkgJson), 'build', 'Release', filename); + return resolveNativeBindingPath(path.join(path.dirname(pkgJson), 'build', 'Release')); } function openDatabase(filename: string, options?: BetterSqlite3.Options): Database { diff --git a/package.json b/package.json index d514ba9..4188dab 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,9 @@ "version-packages": "changeset version", "release": "turbo run build && changeset publish" }, + "pnpm": { + "neverBuiltDependencies": ["better-sqlite3"] + }, "devDependencies": { "@biomejs/biome": "^1.9.4", "@changesets/cli": "^2.27.11",