Selaa lähdekoodia

Self-contained distribution: bundle Node + node:sqlite, drop better-sqlite3/wasm (closes #238) (#282)

* fix(db): eliminate concurrent-read "database is locked"; add node:sqlite backend (#238)

WAL + busy_timeout were already enabled, so the issue's suggested fix was a
no-op. The real causes, addressed here:

- busy_timeout is now set first (before journal_mode) and lowered 120s -> 5s,
  so open-time pragmas wait out a lock instead of hanging for two minutes.
- getCodeGraph no longer opens a second connection to the default project when
  a tool passes its own projectPath (the in-process lock amplifier).
- The wasm fallback (no WAL) gets a bounded read-retry on SQLITE_BUSY.
- New: node:sqlite backend, preferred over wasm, so installs whose native
  better-sqlite3 build fails land on a real-WAL backend instead of no-WAL wasm.
- codegraph status / codegraph_status now report the effective journal mode, so
  a lock report is triageable (wal vs delete).
- CLI hard-blocks Node < 20 to actually enforce the engines floor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(db)!: node:sqlite is the sole backend; drop better-sqlite3 + wasm

Now that distribution will bundle a Node 24 runtime, node:sqlite (real SQLite
with WAL + FTS5) is always available. Collapse the three-backend adapter to
node:sqlite only and remove the machinery the other two needed:

- Remove better-sqlite3 (optionalDependency) and node-sqlite3-wasm (dependency).
- Remove WasmDatabaseAdapter, the named->positional param translation, the
  SQLITE_BUSY read-retry, the wasm fallback banner, the backend env override,
  and the native/node-sqlite/wasm selection chain.
- createDatabase now opens node:sqlite directly, with a clear error pointing at
  the bundled release / Node 22.5+ when the module is absent.
- NodeSqliteAdapter.close() is idempotent and pragma() supports { simple }, to
  match the better-sqlite3 behavior callers relied on.
- status (CLI + MCP) reports the single node:sqlite backend; journal-mode
  diagnostics and the getCodeGraph single-connection fix are retained.
- Tests repointed off better-sqlite3 onto node:sqlite.

Net -1044 lines. Running from source now requires Node 22.5+.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(dist): self-contained bundle prototype (vendored Node + install channels)

Phase 3 of the node:sqlite migration: ship a vendored Node runtime so CodeGraph
runs with no system Node and no native build (node:sqlite is built in).

- scripts/build-bundle.sh: build a per-platform archive (official Node + dist +
  prod deps + launcher). Same recipe per platform; pins Node v24.16.0.
- install.sh: curl|sh installer (no Node required) — detects os/arch, pulls the
  archive from Releases, symlinks onto PATH; re-run to upgrade, --uninstall to
  remove. The VPS/SSH path.
- scripts/npm-shim.js: thin launcher for the npm channel — resolves the
  per-platform optionalDependency bundle and execs it, so `npm i -g` keeps
  working and the real work runs on the bundled Node regardless of the user's.
- BUNDLING.md: distribution design + release-pipeline TODO (CI matrix, platform
  packages, code signing, brew, retiring the Node-version gate).

Validated end-to-end: darwin-arm64 and linux-x64 bundles both run init + index +
status (Backend: node:sqlite, Journal: wal) + FTS query with NO system Node —
linux-x64 verified in a clean ubuntu:24.04 amd64 container. Release archives are
gitignored; CI will produce and upload them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(dist): add Windows PowerShell installer (install.ps1)

The `irm … | iex` one-liner for Windows, mirroring install.sh: detect arch,
pull the matching bundle from Releases, extract to %LOCALAPPDATA%\codegraph,
add it to user PATH. Re-run to upgrade. (Windows bundle production in
build-bundle.sh is still TODO.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(dist): release workflow + npm packaging; README/CHANGELOG for bundled distro

- .github/workflows/release.yml: manually-triggered (workflow_dispatch) release
  matrix. Builds a self-contained bundle per platform on its own runner
  (darwin-arm64/x64, linux-x64/arm64), publishes a GitHub Release with all
  archives, and publishes the npm thin-installer (shim + per-platform packages).
  Windows targets are TODO (build-bundle.sh is unix-only).
- scripts/pack-npm.sh: assemble the npm packages from built bundles — per-platform
  packages tagged os/cpu + the main shim package with them as optionalDependencies
  (esbuild pattern). Proven locally: npm-install the tarballs, run via the shim,
  resolves the bundle and runs on the bundled Node 24 (node:sqlite / WAL).
- README: install section now leads with the no-Node one-liners (curl|sh, irm|iex)
  then npm/npx; "bundled · none required" badge.
- CHANGELOG: standout headline for the self-contained release, plus Added/Changed/
  Removed for the install channels, node:sqlite backend, and dropped deps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(dist): Windows bundles + single-trigger release workflow

- build-bundle.sh: add win32-x64 / win32-arm64 targets — download Node's Windows
  zip, bundle node.exe + a .cmd launcher, output a .zip. Verified structurally
  (PE32+ node.exe, CRLF .cmd, portable node_modules). Since there are no native
  addons, any target builds on any OS, so the whole matrix builds on one runner.
- pack-npm.sh: handle .zip bundles and win32 packages (os: win32, node.exe).
- release.yml: simplified to your spec — manual trigger reads the version from
  package.json, builds all platform bundles, creates the GitHub Release with notes
  pulled from CHANGELOG.md, and publishes the npm shim + platform packages.
- BUNDLING.md: Windows + build-anywhere notes; release pipeline documented.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby Mchenry 1 kuukausi sitten
vanhempi
sitoutus
ac52fd76c0

+ 72 - 0
.github/workflows/release.yml

@@ -0,0 +1,72 @@
+name: Release
+
+# Manually triggered ("Run workflow"). On trigger it:
+#   1. reads the version from package.json,
+#   2. builds a self-contained bundle for every platform (one runner — there's no
+#      native compilation, so cross-packaging is fine),
+#   3. creates the GitHub Release (tag v<version>) with all archives, using the
+#      release notes from CHANGELOG.md,
+#   4. publishes the npm thin-installer (shim + per-platform packages).
+#
+# Before triggering: bump package.json and make sure CHANGELOG.md has the matching
+# section (## [<version>], or ## [Unreleased]). Set the NPM_TOKEN repo secret.
+on:
+  workflow_dispatch: {}
+
+permissions:
+  contents: write   # create the GitHub Release + tag
+
+jobs:
+  release:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-node@v4
+        with:
+          node-version: 22
+          registry-url: https://registry.npmjs.org
+      - run: npm ci
+      - name: Ensure zip/unzip
+        run: sudo apt-get update -qq && sudo apt-get install -y -qq zip unzip
+
+      - name: Build all platform bundles
+        run: |
+          for t in darwin-arm64 darwin-x64 linux-x64 linux-arm64 win32-x64 win32-arm64; do
+            bash scripts/build-bundle.sh "$t"
+          done
+          ls -lh release
+
+      - name: Resolve version
+        id: ver
+        run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
+
+      - name: Release notes from CHANGELOG.md
+        run: |
+          V="${{ steps.ver.outputs.version }}"
+          node scripts/extract-release-notes.mjs "$V" > notes.md 2>/dev/null \
+            || node scripts/extract-release-notes.mjs Unreleased > notes.md 2>/dev/null || true
+          if [ ! -s notes.md ]; then
+            echo "::error::No release notes in CHANGELOG.md for [$V] or [Unreleased]."
+            exit 1
+          fi
+          echo "----- release notes -----"; cat notes.md
+
+      - name: Create GitHub Release
+        env:
+          GH_TOKEN: ${{ github.token }}
+        run: |
+          gh release create "v${{ steps.ver.outputs.version }}" \
+            release/codegraph-* \
+            --title "v${{ steps.ver.outputs.version }}" \
+            --notes-file notes.md
+
+      - name: Publish to npm
+        env:
+          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+        run: |
+          bash scripts/pack-npm.sh "${{ steps.ver.outputs.version }}"
+          # Platform packages first, then the main shim (which depends on them).
+          for dir in release/npm/codegraph-* release/npm/main; do
+            echo "publishing $dir"
+            ( cd "$dir" && npm publish --access public )
+          done

+ 1 - 0
.gitignore

@@ -49,3 +49,4 @@ test_frameworks
 test-languages/
 
 nul
+release/

+ 72 - 0
BUNDLING.md

@@ -0,0 +1,72 @@
+# Distribution: self-contained bundles
+
+CodeGraph ships a **vendored Node runtime** alongside the app. Because Node 22.5+
+has a built-in real SQLite (`node:sqlite`, with WAL + FTS5), bundling Node means:
+
+- **No native build** — `better-sqlite3` is gone, so there are zero native addons
+  to compile or rebuild.
+- **No wasm fallback** — and therefore no more `database is locked` (issue #238).
+- **No Node-version dependence** — the app always runs on the bundled Node,
+  whatever the user has (or doesn't have) installed.
+
+## What's in a bundle
+
+Built by [`scripts/build-bundle.sh`](scripts/build-bundle.sh) — one archive per
+platform, identical recipe (only the Node download differs):
+
+```
+codegraph-<target>/
+  node | node.exe          # official Node runtime for <target>
+  lib/
+    dist/                  # compiled app (+ tree-sitter .wasm grammars, schema.sql)
+    node_modules/          # production deps only (pure JS / wasm — portable)
+  bin/
+    codegraph | codegraph.cmd   # launcher → runs the bundled Node with the app
+```
+
+Targets: `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `win32-x64`,
+`win32-arm64`. Unix targets produce `.tar.gz` (shell launcher); Windows produces
+`.zip` (`node.exe` + a `.cmd` launcher).
+
+```bash
+scripts/build-bundle.sh linux-x64            # -> release/codegraph-linux-x64.tar.gz
+scripts/build-bundle.sh win32-x64            # -> release/codegraph-win32-x64.zip
+```
+
+Because dropping better-sqlite3 left **zero native addons**, building a bundle is
+pure file-packaging — **any** target builds on **any** OS (the whole matrix builds
+on one Linux runner). Cross-compilation isn't a concern; only *run-testing* a
+bundle needs the target platform (or emulation, e.g. `docker run --platform
+linux/amd64`).
+
+## Install channels (all deliver the same bundle)
+
+1. **`curl | sh`** ([`install.sh`](install.sh)) — no Node required; ideal for a
+   fresh Linux VPS over SSH. Detects os/arch, pulls the archive from GitHub
+   Releases, symlinks `codegraph` onto PATH. Re-run to upgrade; `--uninstall` to
+   remove.
+2. **npm** ([`scripts/npm-shim.js`](scripts/npm-shim.js)) — preserves
+   `npm i -g @colbymchenry/codegraph`. The main package is a tiny shim; the
+   bundles ship as per-platform `optionalDependencies`
+   (`@colbymchenry/codegraph-<target>` with `os`/`cpu`), so npm installs only the
+   matching one. The shim — run by the user's Node — execs the bundle, so the
+   real work runs on the bundled Node 24. Works even on old Node.
+3. **Windows** ([`install.ps1`](install.ps1)) — `irm … | iex`; same flow as
+   install.sh (detect arch, pull the `.zip` from Releases, add to PATH).
+4. **Homebrew / Scoop** — TODO (tap + cask pointing at the Release archives).
+
+## Release pipeline
+
+[`.github/workflows/release.yml`](.github/workflows/release.yml) — manually
+triggered. Reads the version from `package.json`, builds every platform bundle on
+one runner, creates the GitHub Release (notes from `CHANGELOG.md`), and publishes
+the npm shim + per-platform packages. Requires the `NPM_TOKEN` repo secret.
+
+Still TODO:
+- **Code signing** — the main gap for "download & run": macOS Gatekeeper needs a
+  Developer ID + notarization; Windows needs Authenticode. Homebrew softens the
+  macOS case (handles quarantine).
+- Retire the now-vestigial Node-version gate in `src/bin/codegraph.ts` — the
+  bundle always runs Node 24, and the npm shim does no tree-sitter work.
+- Re-wire `npm uninstall` cleanup (the agent-config `preuninstall`) through the
+  shim — the generated main package doesn't carry it.

+ 47 - 0
CHANGELOG.md

@@ -9,7 +9,37 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## [Unreleased]
 
+### 🎉 Self-contained: CodeGraph bundles its own runtime — install anywhere, on any Node (or none)
+
+**No more `database is locked`. No more native build failures. No more "WASM fallback active."**
+
+CodeGraph used to need `better-sqlite3`, a native module compiled against your exact
+Node version. When that build failed (common on Windows and locked-down machines) it
+silently dropped to a slow WASM SQLite build with **no WAL** — the root cause of the
+intermittent `database is locked` errors on concurrent MCP tool calls
+([#238](https://github.com/colbymchenry/codegraph/issues/238)). That entire class of
+problem is **gone**: CodeGraph now ships a self-contained Node runtime and uses Node's
+built-in `node:sqlite` (real SQLite, full WAL + FTS5).
+
+- ✅ **Zero native compilation** — nothing to build, ever; nothing to rebuild when Node changes.
+- ✅ **Runs on any Node version — or with no Node at all.** Install via the standalone installers with no Node present, or keep using `npm`/`npx` on any version (your Node only launches the bundled runtime).
+- ✅ **`database is locked` fixed at the root** — real WAL means readers never block on a writer.
+- ⚡ **5–10× faster** than the old WASM fallback for anyone who was stuck on it.
+
+```bash
+# macOS / Linux — no Node required
+curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh | sh
+# Windows (PowerShell) — no Node required
+irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex
+# or, if you have Node (any version):
+npm i -g @colbymchenry/codegraph
+```
+
 ### Added
+- **Standalone installers** — one-line install with no Node.js required:
+  `curl -fsSL .../install.sh | sh` (macOS/Linux) and `irm .../install.ps1 | iex`
+  (Windows). They fetch the matching self-contained bundle from GitHub Releases
+  and put `codegraph` on your PATH.
 - **Lua**: CodeGraph now indexes Lua (`.lua`) — functions, methods (table `t.f`
   and `t:m` definitions become methods with a `t::f` receiver-qualified name),
   local variables, `require(...)` imports, and the call edges between them.
@@ -21,6 +51,23 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
   signatures, generics, and Roblox instance-path `require(script.Parent.X)`
   imports.
 
+### Changed
+- **SQLite backend is now Node's built-in `node:sqlite`** (real SQLite, WAL +
+  FTS5), shipped inside a bundled Node runtime. This fixes the concurrent-read
+  `database is locked` errors ([#238](https://github.com/colbymchenry/codegraph/issues/238))
+  at the root and removes the native build step entirely.
+- **`npm i -g` / `npx` now install a self-contained bundle.** The main package is
+  a tiny shim; the runtime ships as per-platform `optionalDependencies`, so the
+  install works on any Node version (your Node only launches the bundle).
+- **`codegraph status`** now reports the effective journal mode (`wal` vs not),
+  so a `database is locked` report is triageable at a glance.
+
+### Removed
+- **`better-sqlite3`** (optional native dependency) and **`node-sqlite3-wasm`**
+  (WASM fallback) — along with the native-build banner, the WASM fallback path,
+  and the no-WAL lock retries they required. The dependency tree now has zero
+  native addons.
+
 ### Fixed
 - **Installer**: re-running `codegraph install` now removes the broken
   auto-sync hooks that pre-0.8 versions wrote to Claude Code's

+ 20 - 6
README.md

@@ -8,7 +8,7 @@
 
 [![npm version](https://img.shields.io/npm/v/@colbymchenry/codegraph.svg)](https://www.npmjs.com/package/@colbymchenry/codegraph)
 [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
-[![Node.js](https://img.shields.io/badge/Node.js-20--24-green.svg)](https://nodejs.org/)
+[![Self-contained](https://img.shields.io/badge/Node.js-bundled%20%C2%B7%20none%20required-brightgreen.svg)](https://nodejs.org/)
 
 [![Windows](https://img.shields.io/badge/Windows-supported-blue.svg)](#)
 [![macOS](https://img.shields.io/badge/macOS-supported-blue.svg)](#)
@@ -23,11 +23,24 @@
 
 ### Get Started
 
+**No Node.js required** — one command grabs the right build for your OS:
+
 ```bash
-npx @colbymchenry/codegraph
+# macOS / Linux
+curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh | sh
+
+# Windows (PowerShell)
+irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex
+```
+
+Already have Node? Use npm instead (works on any version):
+
+```bash
+npx @colbymchenry/codegraph        # zero-install, or:
+npm i -g @colbymchenry/codegraph
 ```
 
-<sub>Interactive installer auto-configures your agent(s) — Claude Code, Cursor, Codex CLI, opencode</sub>
+<sub>CodeGraph bundles its own runtime — nothing to compile, no native build, works the same everywhere.<br />The interactive installer auto-configures your agent(s) — Claude Code, Cursor, Codex CLI, opencode.</sub>
 
 #### Initialize Projects
 
@@ -456,10 +469,10 @@ The `.codegraph/config.json` file controls indexing:
 
 **Indexing is slow** — Check that `node_modules` and other large directories are excluded. Use `--quiet` to reduce output overhead.
 
-**Indexing is slow / MCP `database is locked` / WASM fallback active** — `codegraph` ships with a WASM SQLite fallback for environments where `better-sqlite3` (a native module, declared as `optionalDependencies`) can't install. The fallback is 5-10x slower than the native backend and uses a journal mode that lets writers block readers, so MCP queries can also hit `database is locked` while indexing runs. Run `codegraph status` and look at the `Backend:` line:
+**Indexing is slow, or MCP hits `database is locked`** — both trace to the SQLite backend. `codegraph` picks the best available, in order: native `better-sqlite3` (fastest; an `optionalDependencies` native module), then Node's built-in `node:sqlite` (Node ≥ 22.5), then a bundled WASM build. Run `codegraph status` and read the **`Backend:`** and **`Journal:`** lines:
 
-- `Backend: native` — you're on the fast path, nothing to do.
-- `Backend: wasm` — you're on the slow fallback. Common causes: missing C build tools, prebuilt binary unavailable for your Node version, or your Node version changed after install. Fix:
+- `Backend: native` or `node:sqlite` with `Journal: wal` — fast path with lock-free concurrent reads; nothing to do.
+- `Backend: wasm` — the native module didn't load *and* `node:sqlite` is unavailable (Node < 22.5). WASM is 5-10x slower and has no WAL, so heavy concurrent use can briefly hit `database is locked`. The simplest fix is Node ≥ 22.5 (you get `node:sqlite` automatically); otherwise restore the native backend:
 
   ```bash
   # macOS
@@ -479,6 +492,7 @@ The `.codegraph/config.json` file controls indexing:
   ```
 
   After the fix, `codegraph status` should show `Backend: native`.
+- `Journal:` shows anything other than `wal` on a `native` / `node:sqlite` backend — WAL couldn't be enabled on this filesystem (common on network shares and WSL2 `/mnt`), so reads can block on writes. Move the project (with its `.codegraph/` folder) onto a local disk.
 
 **MCP server not connecting** — Ensure the project is initialized/indexed, verify the path in your MCP config, and check that `codegraph serve --mcp` works from the command line.
 

+ 152 - 0
__tests__/concurrent-locking.test.ts

@@ -0,0 +1,152 @@
+/**
+ * Issue #238 — "database is locked" on concurrent MCP tool calls.
+ *
+ * With node:sqlite (real WAL) as the backend, the fixes that remain relevant:
+ *  1. busy_timeout is a bounded few-second wait (not a 2-minute hang) and WAL is
+ *     active — so a reader never blocks on a concurrent writer.
+ *  2. The MCP ToolHandler reuses the default instance when a tool passes a
+ *     projectPath pointing at the default project, instead of opening a SECOND
+ *     connection to the same DB.
+ */
+
+import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import CodeGraph from '../src';
+import { ToolHandler } from '../src/mcp/tools';
+import { DatabaseConnection } from '../src/db';
+
+/** Normalize a PRAGMA read across return shapes (array | object | scalar). */
+function pragmaValue(raw: unknown, key: string): unknown {
+  const row = Array.isArray(raw) ? raw[0] : raw;
+  if (row !== null && typeof row === 'object') return (row as Record<string, unknown>)[key];
+  return row;
+}
+
+describe('issue #238 — connection PRAGMAs (#1)', () => {
+  let dir: string;
+  let conn: DatabaseConnection;
+
+  beforeAll(() => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg238-pragma-'));
+    conn = DatabaseConnection.initialize(path.join(dir, 'codegraph.db'));
+  });
+
+  afterAll(() => {
+    conn.close();
+    fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  it('uses a bounded busy_timeout, not the old 2-minute hang', () => {
+    const ms = Number(pragmaValue(conn.getDb().pragma('busy_timeout'), 'timeout'));
+    expect(ms).toBeGreaterThan(0);
+    expect(ms).toBeLessThanOrEqual(30000); // far below the old 120000
+  });
+
+  it('runs in WAL mode — the mode that lets readers proceed during a write', () => {
+    const mode = String(pragmaValue(conn.getDb().pragma('journal_mode'), 'journal_mode')).toLowerCase();
+    expect(mode).toBe('wal');
+  });
+
+  it('getJournalMode() surfaces the effective mode for status triage', () => {
+    expect(conn.getJournalMode()).toBe('wal');
+  });
+});
+
+describe('issue #238 — WAL lets a reader proceed during a writer', () => {
+  let dir: string;
+
+  beforeAll(() => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg238-wal-'));
+  });
+
+  afterAll(() => {
+    fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  it('a read on a 2nd connection succeeds while a writer holds the lock', () => {
+    const dbPath = path.join(dir, 'codegraph.db');
+    const writer = DatabaseConnection.initialize(dbPath);
+    // The property only holds under WAL; skip if the filesystem couldn't enable it.
+    if (writer.getJournalMode() !== 'wal') {
+      writer.close();
+      return;
+    }
+    const reader = DatabaseConnection.open(dbPath);
+    try {
+      writer.getDb().prepare('BEGIN EXCLUSIVE').run(); // hard write lock, held open
+      const t0 = Date.now();
+      const row = reader.getDb().prepare('SELECT COUNT(*) AS c FROM nodes').get() as { c: number };
+      const waited = Date.now() - t0;
+      expect(row.c).toBe(0);
+      expect(waited).toBeLessThan(1000); // proceeds immediately, no busy wait
+    } finally {
+      try { writer.getDb().prepare('COMMIT').run(); } catch { /* ignore */ }
+      reader.close();
+      writer.close();
+    }
+  });
+});
+
+describe('issue #238 — ToolHandler reuses the default instance (#2)', () => {
+  let dir: string;
+  let cg: CodeGraph;
+  let root: string;
+  let handler: ToolHandler;
+
+  beforeAll(async () => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg238-tools-'));
+    fs.writeFileSync(path.join(dir, 'a.ts'), 'export function helper(): number { return 1; }\n');
+    fs.writeFileSync(
+      path.join(dir, 'b.ts'),
+      "import { helper } from './a';\nexport function main(): number { return helper(); }\n"
+    );
+    cg = await CodeGraph.init(dir, { index: true });
+    root = cg.getProjectRoot();
+    handler = new ToolHandler(cg);
+  });
+
+  afterAll(() => {
+    cg.close();
+    fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  it('getCodeGraph(defaultRoot) returns the default instance, not a new connection', () => {
+    const openSpy = vi.spyOn(CodeGraph, 'openSync');
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const resolved = (handler as any).getCodeGraph(root);
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const nested = (handler as any).getCodeGraph(path.join(root, 'does', 'not', 'exist'));
+      expect(resolved).toBe(cg);
+      expect(nested).toBe(cg); // a sub-path resolves up to the same default project
+      expect(openSpy).not.toHaveBeenCalled(); // no second connection opened
+    } finally {
+      openSpy.mockRestore();
+    }
+  });
+
+  it('concurrent read tool calls (mixed projectPath) all succeed without "database is locked"', async () => {
+    const openSpy = vi.spyOn(CodeGraph, 'openSync');
+    try {
+      const calls: Promise<{ content: Array<{ text: string }>; isError?: boolean }>[] = [
+        handler.execute('codegraph_search', { query: 'helper' }),
+        handler.execute('codegraph_search', { query: 'helper', projectPath: root }),
+        handler.execute('codegraph_callers', { symbol: 'helper', projectPath: root }),
+        handler.execute('codegraph_callees', { symbol: 'main' }),
+        handler.execute('codegraph_files', { projectPath: root }),
+        handler.execute('codegraph_status', { projectPath: root }),
+      ];
+      const results = await Promise.all(calls);
+      for (const r of results) {
+        expect(r.isError).not.toBe(true);
+        expect(r.content[0]?.text ?? '').not.toMatch(/database is locked/i);
+      }
+      // Passing the default project's own path must not open a second connection.
+      expect(openSpy).not.toHaveBeenCalled();
+    } finally {
+      openSpy.mockRestore();
+    }
+  });
+});

+ 71 - 0
__tests__/node-sqlite-backend.test.ts

@@ -0,0 +1,71 @@
+/**
+ * node:sqlite backend (issue #238 follow-up).
+ *
+ * node:sqlite (Node's built-in real SQLite) is now the sole backend. This drives
+ * a real index + queries through it, so WAL, FTS5 search, and @named-param
+ * writes are all exercised end-to-end.
+ *
+ * Skipped on Node < 22.5 where node:sqlite doesn't exist.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import CodeGraph from '../src';
+
+let nodeSqliteAvailable = false;
+try {
+  // eslint-disable-next-line @typescript-eslint/no-require-imports
+  require('node:sqlite');
+  nodeSqliteAvailable = true;
+} catch {
+  nodeSqliteAvailable = false;
+}
+
+describe.skipIf(!nodeSqliteAvailable)('node:sqlite backend — real index + queries', () => {
+  let dir: string;
+  let cg: CodeGraph;
+
+  beforeAll(async () => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-nodesqlite-'));
+    fs.writeFileSync(path.join(dir, 'a.ts'), 'export function helper(): number { return 1; }\n');
+    fs.writeFileSync(
+      path.join(dir, 'b.ts'),
+      "import { helper } from './a';\nexport function main(): number { return helper(); }\n"
+    );
+    cg = await CodeGraph.init(dir, { index: true });
+  });
+
+  afterAll(() => {
+    cg?.close();
+    fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  it('uses the node:sqlite backend', () => {
+    expect(cg.getBackend()).toBe('node-sqlite');
+  });
+
+  it('runs in WAL mode — the whole reason it beats the wasm fallback', () => {
+    expect(cg.getJournalMode()).toBe('wal');
+  });
+
+  it('indexed the project (write path: @named-param INSERTs via node:sqlite)', () => {
+    const stats = cg.getStats();
+    expect(stats.fileCount).toBe(2);
+    expect(stats.nodeCount).toBeGreaterThan(0);
+  });
+
+  it('FTS5 search returns the indexed symbol (read path)', () => {
+    const results = cg.searchNodes('helper');
+    const names = results.map(r => r.node.name);
+    expect(names).toContain('helper');
+  });
+
+  it('graph traversal resolves the cross-file caller', () => {
+    const helper = cg.searchNodes('helper').find(r => r.node.name === 'helper');
+    expect(helper).toBeTruthy();
+    const callers = cg.getCallers(helper!.node.id);
+    expect(callers.map(c => c.node.name)).toContain('main');
+  });
+});

+ 27 - 1
__tests__/node-version-check.test.ts

@@ -7,7 +7,7 @@
  */
 
 import { describe, it, expect } from 'vitest';
-import { buildNode25BlockBanner } from '../src/bin/node-version-check';
+import { buildNode25BlockBanner, buildNodeTooOldBanner, MIN_NODE_MAJOR } from '../src/bin/node-version-check';
 
 describe('buildNode25BlockBanner', () => {
   it('embeds the reported Node version in the header', () => {
@@ -41,3 +41,29 @@ describe('buildNode25BlockBanner', () => {
     );
   });
 });
+
+describe('buildNodeTooOldBanner', () => {
+  it('embeds the reported Node version in the header', () => {
+    expect(buildNodeTooOldBanner('18.20.0')).toContain(
+      'Unsupported Node.js version: 18.20.0'
+    );
+  });
+
+  it('states the supported floor matching MIN_NODE_MAJOR', () => {
+    expect(MIN_NODE_MAJOR).toBe(20);
+    expect(buildNodeTooOldBanner('18.0.0')).toContain(
+      `requires Node.js ${MIN_NODE_MAJOR} or newer`
+    );
+  });
+
+  it('points users to Node 22 LTS via nvm and Homebrew', () => {
+    const banner = buildNodeTooOldBanner('16.0.0');
+    expect(banner).toContain('Node.js 22 LTS');
+    expect(banner).toContain('nvm install 22');
+    expect(banner).toContain('brew install node@22');
+  });
+
+  it('documents the CODEGRAPH_ALLOW_UNSAFE_NODE override', () => {
+    expect(buildNodeTooOldBanner('18.0.0')).toContain('CODEGRAPH_ALLOW_UNSAFE_NODE=1');
+  });
+});

+ 3 - 3
__tests__/pr19-improvements.test.ts

@@ -45,11 +45,11 @@ function cleanupTempDir(dir: string): void {
   }
 }
 
-// Check if better-sqlite3 native bindings are available
+// Check if the node:sqlite backend is available (Node >= 22.5)
 function hasSqliteBindings(): boolean {
   try {
-    const Database = require('better-sqlite3');
-    const db = new Database(':memory:');
+    const { DatabaseSync } = require('node:sqlite');
+    const db = new DatabaseSync(':memory:');
     db.close();
     return true;
   } catch {

+ 9 - 51
__tests__/sqlite-backend.test.ts

@@ -1,59 +1,18 @@
 /**
- * SQLite backend visibility tests
+ * SQLite backend reporting.
  *
- * Pins the WASM-fallback banner content + the per-instance backend
- * tracking. Closes the visibility gap behind issue #138.
+ * node:sqlite (Node's built-in real SQLite) is the sole backend. Pin that
+ * DatabaseConnection / CodeGraph report it and come up in WAL.
  */
 
 import { describe, it, expect, beforeEach, afterEach } from 'vitest';
 import * as fs from 'fs';
 import * as path from 'path';
 import * as os from 'os';
-import {
-  buildWasmFallbackBanner,
-  WASM_FALLBACK_FIX_RECIPE,
-} from '../src/db/sqlite-adapter';
 import { DatabaseConnection } from '../src/db';
 import { CodeGraph } from '../src';
 
-describe('buildWasmFallbackBanner — fix-recipe content', () => {
-  it('includes the macOS / Linux / cross-platform fix commands', () => {
-    const banner = buildWasmFallbackBanner();
-    expect(banner).toContain('WASM SQLite fallback active');
-    expect(banner).toContain('5-10x slower');
-    expect(banner).toContain('xcode-select --install');
-    expect(banner).toContain('apt install build-essential');
-    expect(banner).toContain('npm rebuild better-sqlite3');
-    expect(banner).toContain('npm install better-sqlite3 --save');
-    expect(banner).toContain('codegraph status');
-  });
-
-  it('appends the native load error when one is provided', () => {
-    const banner = buildWasmFallbackBanner(
-      "Cannot find module 'better-sqlite3'"
-    );
-    expect(banner).toContain(
-      "Native load error: Cannot find module 'better-sqlite3'"
-    );
-  });
-
-  it('omits the load-error block when no error is supplied', () => {
-    const banner = buildWasmFallbackBanner();
-    expect(banner).not.toContain('Native load error:');
-  });
-});
-
-describe('WASM_FALLBACK_FIX_RECIPE — single source of truth', () => {
-  it('mentions the three recovery commands', () => {
-    expect(WASM_FALLBACK_FIX_RECIPE).toContain('xcode-select --install');
-    expect(WASM_FALLBACK_FIX_RECIPE).toContain('npm rebuild better-sqlite3');
-    expect(WASM_FALLBACK_FIX_RECIPE).toContain(
-      'npm install better-sqlite3 --save'
-    );
-  });
-});
-
-describe('DatabaseConnection — per-instance backend reporting', () => {
+describe('DatabaseConnection — backend reporting', () => {
   let dir: string;
 
   beforeEach(() => {
@@ -66,11 +25,10 @@ describe('DatabaseConnection — per-instance backend reporting', () => {
     }
   });
 
-  it('reports a concrete backend (native or wasm) for an initialized DB', () => {
-    const dbPath = path.join(dir, 'test.db');
-    const conn = DatabaseConnection.initialize(dbPath);
-    const backend = conn.getBackend();
-    expect(['native', 'wasm']).toContain(backend);
+  it('reports the node-sqlite backend in WAL for an initialized DB', () => {
+    const conn = DatabaseConnection.initialize(path.join(dir, 'test.db'));
+    expect(conn.getBackend()).toBe('node-sqlite');
+    expect(conn.getJournalMode()).toBe('wal');
     conn.close();
   });
 
@@ -78,7 +36,7 @@ describe('DatabaseConnection — per-instance backend reporting', () => {
     fs.writeFileSync(path.join(dir, 'x.ts'), `export function x(): void {}\n`);
     const cg = await CodeGraph.init(dir, { index: true });
     try {
-      expect(['native', 'wasm']).toContain(cg.getBackend());
+      expect(cg.getBackend()).toBe('node-sqlite');
     } finally {
       cg.destroy();
     }

+ 2 - 2
__tests__/symbol-lookup.test.ts

@@ -25,8 +25,8 @@ beforeAll(async () => {
 
 function hasSqliteBindings(): boolean {
   try {
-    const Database = require('better-sqlite3');
-    const db = new Database(':memory:');
+    const { DatabaseSync } = require('node:sqlite');
+    const db = new DatabaseSync(':memory:');
     db.close();
     return true;
   } catch {

+ 59 - 0
install.ps1

@@ -0,0 +1,59 @@
+# CodeGraph standalone installer for Windows (PowerShell).
+#
+# Downloads a self-contained bundle (a vendored Node runtime + the app) from
+# GitHub Releases. No Node.js, no build tools required.
+#
+#   irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex
+#
+# Re-run to upgrade. To uninstall: remove $env:LOCALAPPDATA\codegraph and drop
+# its \current\bin entry from your user PATH.
+#
+# Environment:
+#   CODEGRAPH_VERSION      release tag to install (default: latest)
+#   CODEGRAPH_INSTALL_DIR  install location (default: %LOCALAPPDATA%\codegraph)
+
+$ErrorActionPreference = 'Stop'
+$repo = 'colbymchenry/codegraph'
+$installDir = if ($env:CODEGRAPH_INSTALL_DIR) { $env:CODEGRAPH_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'codegraph' }
+
+# 1. Detect architecture -> target matching the release archives.
+$arch = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq 'Arm64') { 'arm64' } else { 'x64' }
+$target = "win32-$arch"
+
+# 2. Resolve the version (latest release unless pinned).
+$version = $env:CODEGRAPH_VERSION
+if (-not $version) {
+  $version = (Invoke-RestMethod "https://api.github.com/repos/$repo/releases/latest").tag_name
+}
+if (-not $version) { throw "codegraph: could not resolve latest version; set CODEGRAPH_VERSION." }
+
+# 3. Download + extract the bundle into a stable 'current' dir (overwritten on upgrade).
+$url = "https://github.com/$repo/releases/download/$version/codegraph-$target.zip"
+Write-Host "Installing CodeGraph $version ($target)..."
+$tmp = Join-Path $env:TEMP ("cg-" + [guid]::NewGuid().ToString())
+New-Item -ItemType Directory -Force -Path $tmp | Out-Null
+$zip = Join-Path $tmp 'cg.zip'
+Invoke-WebRequest -Uri $url -OutFile $zip
+
+$dest = Join-Path $installDir 'current'
+if (Test-Path $dest) { Remove-Item -Recurse -Force $dest }
+New-Item -ItemType Directory -Force -Path $dest | Out-Null
+Expand-Archive -Path $zip -DestinationPath $dest -Force
+# Archives contain a top-level codegraph-<target>\ dir; flatten it.
+$inner = Join-Path $dest "codegraph-$target"
+if (Test-Path $inner) {
+  Get-ChildItem -Force $inner | Move-Item -Destination $dest -Force
+  Remove-Item -Recurse -Force $inner
+}
+Remove-Item -Recurse -Force $tmp
+
+# 4. Put the launcher dir on the user's PATH.
+$binDir = Join-Path $dest 'bin'
+$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
+if (($userPath -split ';') -notcontains $binDir) {
+  [Environment]::SetEnvironmentVariable('Path', "$binDir;$userPath", 'User')
+  Write-Host "Added $binDir to your PATH (restart your terminal to pick it up)."
+}
+
+Write-Host "Installed to $dest"
+Write-Host "Run: codegraph --help"

+ 83 - 0
install.sh

@@ -0,0 +1,83 @@
+#!/bin/sh
+#
+# CodeGraph standalone installer.
+#
+# Downloads a self-contained bundle (a vendored Node runtime + the app) from
+# GitHub Releases. No Node.js, no build tools, no npm required — ideal for a
+# fresh Linux VPS over SSH.
+#
+#   curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh | sh
+#
+# Upgrade:   re-run the same command.
+# Uninstall: curl -fsSL .../install.sh | sh -s -- --uninstall
+#
+# Environment:
+#   CODEGRAPH_VERSION      release tag to install (default: latest)
+#   CODEGRAPH_INSTALL_DIR  bundle location   (default: ~/.codegraph)
+#   CODEGRAPH_BIN_DIR      symlink location  (default: ~/.local/bin)
+set -eu
+
+REPO="colbymchenry/codegraph"
+INSTALL_DIR="${CODEGRAPH_INSTALL_DIR:-$HOME/.codegraph}"
+BIN_DIR="${CODEGRAPH_BIN_DIR:-$HOME/.local/bin}"
+
+if [ "${1:-}" = "--uninstall" ]; then
+  rm -f "$BIN_DIR/codegraph"
+  rm -rf "$INSTALL_DIR"
+  echo "CodeGraph uninstalled (removed $INSTALL_DIR and $BIN_DIR/codegraph)."
+  exit 0
+fi
+
+# 1. Detect platform → target triple matching the release archives.
+os="$(uname -s)"
+arch="$(uname -m)"
+case "$os" in
+  Darwin) os="darwin" ;;
+  Linux)  os="linux" ;;
+  *) echo "codegraph: unsupported OS '$os'." >&2; exit 1 ;;
+esac
+case "$arch" in
+  arm64|aarch64) arch="arm64" ;;
+  x86_64|amd64)  arch="x64" ;;
+  *) echo "codegraph: unsupported architecture '$arch'." >&2; exit 1 ;;
+esac
+target="${os}-${arch}"
+
+# 2. Resolve the version (latest release unless pinned).
+version="${CODEGRAPH_VERSION:-}"
+if [ -z "$version" ]; then
+  version="$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" \
+    | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n1)"
+fi
+[ -n "$version" ] || { echo "codegraph: could not resolve latest version; set CODEGRAPH_VERSION." >&2; exit 1; }
+
+# 3. Download + extract the bundle.
+url="https://github.com/$REPO/releases/download/$version/codegraph-${target}.tar.gz"
+echo "Installing CodeGraph $version ($target)..."
+tmp="$(mktemp -d)"
+trap 'rm -rf "$tmp"' EXIT
+curl -fsSL "$url" -o "$tmp/cg.tar.gz" || { echo "codegraph: download failed: $url" >&2; exit 1; }
+
+dest="$INSTALL_DIR/versions/$version"
+rm -rf "$dest"
+mkdir -p "$dest"
+# Archives contain a top-level codegraph-<target>/ dir; strip it.
+tar -xzf "$tmp/cg.tar.gz" -C "$dest" --strip-components=1
+
+# 4. Symlink the launcher onto PATH and mark the current version.
+mkdir -p "$BIN_DIR"
+ln -sf "$dest/bin/codegraph" "$BIN_DIR/codegraph"
+ln -sfn "$dest" "$INSTALL_DIR/current"
+
+echo "Installed to $dest"
+echo "Linked     $BIN_DIR/codegraph"
+case ":$PATH:" in
+  *":$BIN_DIR:"*) ;;
+  *)
+    echo ""
+    echo "$BIN_DIR is not on your PATH. Add it:"
+    echo "  export PATH=\"$BIN_DIR:\$PATH\""
+    ;;
+esac
+echo ""
+echo "Done. Run: codegraph --help"

+ 0 - 499
package-lock.json

@@ -14,7 +14,6 @@
         "fast-string-width": "^3.0.2",
         "fast-wrap-ansi": "^0.2.0",
         "jsonc-parser": "^3.3.1",
-        "node-sqlite3-wasm": "^0.8.30",
         "picomatch": "^4.0.3",
         "sisteransi": "^1.0.5",
         "tree-sitter-wasms": "^0.1.11",
@@ -32,9 +31,6 @@
       },
       "engines": {
         "node": ">=20.0.0 <25.0.0"
-      },
-      "optionalDependencies": {
-        "better-sqlite3": "^12.4.1"
       }
     },
     "node_modules/@clack/core": {
@@ -970,89 +966,6 @@
         "node": ">=12"
       }
     },
-    "node_modules/base64-js": {
-      "version": "1.5.1",
-      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
-      "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/feross"
-        },
-        {
-          "type": "patreon",
-          "url": "https://www.patreon.com/feross"
-        },
-        {
-          "type": "consulting",
-          "url": "https://feross.org/support"
-        }
-      ],
-      "license": "MIT",
-      "optional": true
-    },
-    "node_modules/better-sqlite3": {
-      "version": "12.10.0",
-      "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz",
-      "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==",
-      "hasInstallScript": true,
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "bindings": "^1.5.0",
-        "prebuild-install": "^7.1.1"
-      },
-      "engines": {
-        "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
-      }
-    },
-    "node_modules/bindings": {
-      "version": "1.5.0",
-      "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
-      "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "file-uri-to-path": "1.0.0"
-      }
-    },
-    "node_modules/bl": {
-      "version": "4.1.0",
-      "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
-      "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "buffer": "^5.5.0",
-        "inherits": "^2.0.4",
-        "readable-stream": "^3.4.0"
-      }
-    },
-    "node_modules/buffer": {
-      "version": "5.7.1",
-      "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
-      "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/feross"
-        },
-        {
-          "type": "patreon",
-          "url": "https://www.patreon.com/feross"
-        },
-        {
-          "type": "consulting",
-          "url": "https://feross.org/support"
-        }
-      ],
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "base64-js": "^1.3.1",
-        "ieee754": "^1.1.13"
-      }
-    },
     "node_modules/cac": {
       "version": "6.7.14",
       "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@@ -1090,13 +1003,6 @@
         "node": ">= 16"
       }
     },
-    "node_modules/chownr": {
-      "version": "1.1.4",
-      "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
-      "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
-      "license": "ISC",
-      "optional": true
-    },
     "node_modules/commander": {
       "version": "14.0.3",
       "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
@@ -1124,22 +1030,6 @@
         }
       }
     },
-    "node_modules/decompress-response": {
-      "version": "6.0.0",
-      "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
-      "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "mimic-response": "^3.1.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
     "node_modules/deep-eql": {
       "version": "5.0.2",
       "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@@ -1150,36 +1040,6 @@
         "node": ">=6"
       }
     },
-    "node_modules/deep-extend": {
-      "version": "0.6.0",
-      "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
-      "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
-      "license": "MIT",
-      "optional": true,
-      "engines": {
-        "node": ">=4.0.0"
-      }
-    },
-    "node_modules/detect-libc": {
-      "version": "2.1.2",
-      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
-      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
-      "license": "Apache-2.0",
-      "optional": true,
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/end-of-stream": {
-      "version": "1.4.5",
-      "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
-      "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "once": "^1.4.0"
-      }
-    },
     "node_modules/es-module-lexer": {
       "version": "1.7.0",
       "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@@ -1236,16 +1096,6 @@
         "@types/estree": "^1.0.0"
       }
     },
-    "node_modules/expand-template": {
-      "version": "2.0.3",
-      "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
-      "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
-      "license": "(MIT OR WTFPL)",
-      "optional": true,
-      "engines": {
-        "node": ">=6"
-      }
-    },
     "node_modules/expect-type": {
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -1280,20 +1130,6 @@
         "fast-string-width": "^3.0.2"
       }
     },
-    "node_modules/file-uri-to-path": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
-      "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
-      "license": "MIT",
-      "optional": true
-    },
-    "node_modules/fs-constants": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
-      "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
-      "license": "MIT",
-      "optional": true
-    },
     "node_modules/fsevents": {
       "version": "2.3.3",
       "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1309,48 +1145,6 @@
         "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
       }
     },
-    "node_modules/github-from-package": {
-      "version": "0.0.0",
-      "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
-      "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
-      "license": "MIT",
-      "optional": true
-    },
-    "node_modules/ieee754": {
-      "version": "1.2.1",
-      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
-      "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/feross"
-        },
-        {
-          "type": "patreon",
-          "url": "https://www.patreon.com/feross"
-        },
-        {
-          "type": "consulting",
-          "url": "https://feross.org/support"
-        }
-      ],
-      "license": "BSD-3-Clause",
-      "optional": true
-    },
-    "node_modules/inherits": {
-      "version": "2.0.4",
-      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
-      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
-      "license": "ISC",
-      "optional": true
-    },
-    "node_modules/ini": {
-      "version": "1.3.8",
-      "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
-      "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
-      "license": "ISC",
-      "optional": true
-    },
     "node_modules/jsonc-parser": {
       "version": "3.3.1",
       "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
@@ -1374,36 +1168,6 @@
         "@jridgewell/sourcemap-codec": "^1.5.5"
       }
     },
-    "node_modules/mimic-response": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
-      "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
-      "license": "MIT",
-      "optional": true,
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/minimist": {
-      "version": "1.2.8",
-      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
-      "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
-      "license": "MIT",
-      "optional": true,
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/mkdirp-classic": {
-      "version": "0.5.3",
-      "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
-      "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
-      "license": "MIT",
-      "optional": true
-    },
     "node_modules/ms": {
       "version": "2.1.3",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1430,42 +1194,6 @@
         "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
       }
     },
-    "node_modules/napi-build-utils": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
-      "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
-      "license": "MIT",
-      "optional": true
-    },
-    "node_modules/node-abi": {
-      "version": "3.87.0",
-      "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz",
-      "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==",
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "semver": "^7.3.5"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/node-sqlite3-wasm": {
-      "version": "0.8.53",
-      "resolved": "https://registry.npmjs.org/node-sqlite3-wasm/-/node-sqlite3-wasm-0.8.53.tgz",
-      "integrity": "sha512-HPuGOPj3L+h3WSf0XikIXTDpsRxlVmzBC3RMgqi3yDg9CEbm/4Hw3rrDodeITqITjm07X4atWLlDMMI8KERMiQ==",
-      "license": "MIT"
-    },
-    "node_modules/once": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
-      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
-      "license": "ISC",
-      "optional": true,
-      "dependencies": {
-        "wrappy": "1"
-      }
-    },
     "node_modules/pathe": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
@@ -1531,75 +1259,6 @@
         "node": "^10 || ^12 || >=14"
       }
     },
-    "node_modules/prebuild-install": {
-      "version": "7.1.3",
-      "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
-      "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "detect-libc": "^2.0.0",
-        "expand-template": "^2.0.3",
-        "github-from-package": "0.0.0",
-        "minimist": "^1.2.3",
-        "mkdirp-classic": "^0.5.3",
-        "napi-build-utils": "^2.0.0",
-        "node-abi": "^3.3.0",
-        "pump": "^3.0.0",
-        "rc": "^1.2.7",
-        "simple-get": "^4.0.0",
-        "tar-fs": "^2.0.0",
-        "tunnel-agent": "^0.6.0"
-      },
-      "bin": {
-        "prebuild-install": "bin.js"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/pump": {
-      "version": "3.0.3",
-      "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
-      "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "end-of-stream": "^1.1.0",
-        "once": "^1.3.1"
-      }
-    },
-    "node_modules/rc": {
-      "version": "1.2.8",
-      "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
-      "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
-      "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
-      "optional": true,
-      "dependencies": {
-        "deep-extend": "^0.6.0",
-        "ini": "~1.3.0",
-        "minimist": "^1.2.0",
-        "strip-json-comments": "~2.0.1"
-      },
-      "bin": {
-        "rc": "cli.js"
-      }
-    },
-    "node_modules/readable-stream": {
-      "version": "3.6.2",
-      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
-      "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "inherits": "^2.0.3",
-        "string_decoder": "^1.1.1",
-        "util-deprecate": "^1.0.1"
-      },
-      "engines": {
-        "node": ">= 6"
-      }
-    },
     "node_modules/rollup": {
       "version": "4.57.1",
       "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
@@ -1645,40 +1304,6 @@
         "fsevents": "~2.3.2"
       }
     },
-    "node_modules/safe-buffer": {
-      "version": "5.2.1",
-      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
-      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/feross"
-        },
-        {
-          "type": "patreon",
-          "url": "https://www.patreon.com/feross"
-        },
-        {
-          "type": "consulting",
-          "url": "https://feross.org/support"
-        }
-      ],
-      "license": "MIT",
-      "optional": true
-    },
-    "node_modules/semver": {
-      "version": "7.7.4",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
-      "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
-      "license": "ISC",
-      "optional": true,
-      "bin": {
-        "semver": "bin/semver.js"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
     "node_modules/siginfo": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -1686,53 +1311,6 @@
       "dev": true,
       "license": "ISC"
     },
-    "node_modules/simple-concat": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
-      "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/feross"
-        },
-        {
-          "type": "patreon",
-          "url": "https://www.patreon.com/feross"
-        },
-        {
-          "type": "consulting",
-          "url": "https://feross.org/support"
-        }
-      ],
-      "license": "MIT",
-      "optional": true
-    },
-    "node_modules/simple-get": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
-      "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/feross"
-        },
-        {
-          "type": "patreon",
-          "url": "https://www.patreon.com/feross"
-        },
-        {
-          "type": "consulting",
-          "url": "https://feross.org/support"
-        }
-      ],
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "decompress-response": "^6.0.0",
-        "once": "^1.3.1",
-        "simple-concat": "^1.0.0"
-      }
-    },
     "node_modules/sisteransi": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -1763,56 +1341,6 @@
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/string_decoder": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
-      "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "safe-buffer": "~5.2.0"
-      }
-    },
-    "node_modules/strip-json-comments": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
-      "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
-      "license": "MIT",
-      "optional": true,
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/tar-fs": {
-      "version": "2.1.4",
-      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
-      "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "chownr": "^1.1.1",
-        "mkdirp-classic": "^0.5.2",
-        "pump": "^3.0.0",
-        "tar-stream": "^2.1.4"
-      }
-    },
-    "node_modules/tar-stream": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
-      "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "bl": "^4.0.3",
-        "end-of-stream": "^1.4.1",
-        "fs-constants": "^1.0.0",
-        "inherits": "^2.0.3",
-        "readable-stream": "^3.1.1"
-      },
-      "engines": {
-        "node": ">=6"
-      }
-    },
     "node_modules/tinybench": {
       "version": "2.9.0",
       "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -1866,19 +1394,6 @@
         "tree-sitter-wasms": "^0.1.11"
       }
     },
-    "node_modules/tunnel-agent": {
-      "version": "0.6.0",
-      "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
-      "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
-      "license": "Apache-2.0",
-      "optional": true,
-      "dependencies": {
-        "safe-buffer": "^5.0.1"
-      },
-      "engines": {
-        "node": "*"
-      }
-    },
     "node_modules/typescript": {
       "version": "5.9.3",
       "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1900,13 +1415,6 @@
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/util-deprecate": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
-      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
-      "license": "MIT",
-      "optional": true
-    },
     "node_modules/vite": {
       "version": "5.4.21",
       "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@@ -2087,13 +1595,6 @@
       "engines": {
         "node": ">=8"
       }
-    },
-    "node_modules/wrappy": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
-      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
-      "license": "ISC",
-      "optional": true
     }
   }
 }

+ 0 - 4
package.json

@@ -37,7 +37,6 @@
     "fast-string-width": "^3.0.2",
     "fast-wrap-ansi": "^0.2.0",
     "jsonc-parser": "^3.3.1",
-    "node-sqlite3-wasm": "^0.8.30",
     "picomatch": "^4.0.3",
     "sisteransi": "^1.0.5",
     "tree-sitter-wasms": "^0.1.11",
@@ -50,9 +49,6 @@
     "typescript": "^5.0.0",
     "vitest": "^2.1.9"
   },
-  "optionalDependencies": {
-    "better-sqlite3": "^12.4.1"
-  },
   "engines": {
     "node": ">=20.0.0 <25.0.0"
   }

+ 98 - 0
scripts/build-bundle.sh

@@ -0,0 +1,98 @@
+#!/usr/bin/env bash
+#
+# Build a self-contained CodeGraph bundle: an official Node runtime + the
+# compiled app + its production deps, so CodeGraph runs with NO system Node and
+# NO native build — node:sqlite is built into the bundled Node. One archive per
+# platform.
+#
+# Because dropping better-sqlite3 left zero native addons, the recipe is pure
+# file-packaging (download the target's Node, copy the app, archive) — so any
+# platform's bundle can be built on any OS. No cross-compile, no native runners.
+#
+# Usage:
+#   scripts/build-bundle.sh <target> [node-version]
+#     target:        darwin-arm64 | darwin-x64 | linux-x64 | linux-arm64
+#                  | win32-x64 | win32-arm64
+#     node-version:  e.g. v24.16.0 (default below; pin for reproducible builds)
+#
+# Output:
+#   unix:    release/codegraph-<target>.tar.gz   (launcher: bin/codegraph)
+#   windows: release/codegraph-<target>.zip      (launcher: bin/codegraph.cmd)
+set -euo pipefail
+
+TARGET="${1:?usage: build-bundle.sh <target> [node-version]}"
+NODE_VERSION="${2:-v24.16.0}"
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+OUT="$ROOT/release"
+WORK="$(mktemp -d)"
+trap 'rm -rf "$WORK"' EXIT
+
+ARCH="${TARGET##*-}"   # x64 | arm64
+OSFAM="${TARGET%-*}"   # darwin | linux | win32
+
+echo "[bundle] target=${TARGET} node=${NODE_VERSION}"
+
+# 1. Download + extract the official Node runtime for the target platform.
+if [ "$OSFAM" = "win32" ]; then
+  NODE_DIST="node-${NODE_VERSION}-win-${ARCH}"
+  NODE_URL="https://nodejs.org/dist/${NODE_VERSION}/${NODE_DIST}.zip"
+  echo "[bundle] downloading ${NODE_URL}"
+  curl -fsSL "$NODE_URL" -o "$WORK/node.zip"
+  if command -v unzip >/dev/null 2>&1; then
+    unzip -q "$WORK/node.zip" -d "$WORK"
+  else
+    tar -xf "$WORK/node.zip" -C "$WORK"   # bsdtar can read zip
+  fi
+  NODE_BIN="$WORK/${NODE_DIST}/node.exe"
+else
+  NODE_DIST="node-${NODE_VERSION}-${TARGET}"
+  NODE_URL="https://nodejs.org/dist/${NODE_VERSION}/${NODE_DIST}.tar.gz"
+  echo "[bundle] downloading ${NODE_URL}"
+  curl -fsSL "$NODE_URL" -o "$WORK/node.tar.gz"
+  tar -xzf "$WORK/node.tar.gz" -C "$WORK"
+  NODE_BIN="$WORK/${NODE_DIST}/bin/node"
+fi
+[ -f "$NODE_BIN" ] || { echo "[bundle] error: node binary not found ($NODE_BIN)" >&2; exit 1; }
+
+# 2. Build the app (compiled JS + copied wasm/schema assets).
+echo "[bundle] building app"
+( cd "$ROOT" && npm run build >/dev/null )
+
+# 3. Stage: app + production-only deps (pure JS/wasm → portable across platforms).
+STAGE="$WORK/codegraph-${TARGET}"
+mkdir -p "$STAGE/lib" "$STAGE/bin"
+cp -R "$ROOT/dist" "$STAGE/lib/dist"
+cp "$ROOT/package.json" "$ROOT/package-lock.json" "$STAGE/lib/"
+echo "[bundle] installing production dependencies"
+( cd "$STAGE/lib" && npm ci --omit=dev --ignore-scripts >/dev/null 2>&1 )
+rm -f "$STAGE/lib/package-lock.json"
+
+# 4. Vendored Node + launcher (the launcher uses the bundled Node by relative
+#    path, so no system Node is ever needed).
+if [ "$OSFAM" = "win32" ]; then
+  cp "$NODE_BIN" "$STAGE/node.exe"
+  printf '@"%%~dp0..\\node.exe" "%%~dp0..\\lib\\dist\\bin\\codegraph.js" %%*\r\n' \
+    > "$STAGE/bin/codegraph.cmd"
+else
+  cp "$NODE_BIN" "$STAGE/node"
+  cat > "$STAGE/bin/codegraph" <<'LAUNCH'
+#!/bin/sh
+DIR="$(cd "$(dirname "$0")/.." && pwd)"
+exec "$DIR/node" "$DIR/lib/dist/bin/codegraph.js" "$@"
+LAUNCH
+  chmod +x "$STAGE/bin/codegraph"
+fi
+
+# 5. Archive (.zip for Windows, .tar.gz otherwise).
+mkdir -p "$OUT"
+if [ "$OSFAM" = "win32" ]; then
+  ARCHIVE="$OUT/codegraph-${TARGET}.zip"
+  rm -f "$ARCHIVE"
+  ( cd "$WORK" && zip -rqX "$ARCHIVE" "codegraph-${TARGET}" )
+else
+  ARCHIVE="$OUT/codegraph-${TARGET}.tar.gz"
+  # --no-xattrs: don't embed macOS xattrs that make GNU tar warn on Linux.
+  tar --no-xattrs -czf "$ARCHIVE" -C "$WORK" "codegraph-${TARGET}"
+fi
+echo "[bundle] wrote ${ARCHIVE} ($(du -h "$ARCHIVE" | cut -f1))"

+ 43 - 0
scripts/npm-shim.js

@@ -0,0 +1,43 @@
+#!/usr/bin/env node
+'use strict';
+//
+// npm thin-installer launcher for CodeGraph.
+//
+// The heavy artifact (a vendored Node runtime + the app) ships as a per-platform
+// optionalDependency: @colbymchenry/codegraph-<platform>-<arch>. npm installs
+// only the one matching the host, via each package's `os`/`cpu` fields (the
+// esbuild pattern). This shim — run by the user's OWN Node — locates that bundle
+// and execs its launcher, so the real work always runs on the bundled Node 24
+// (with node:sqlite), regardless of the user's Node version. The user's Node is
+// only ever a launcher; even an ancient version can run this file.
+//
+// Wired up at release time as the main package's `bin`:
+//   "bin": { "codegraph": "scripts/npm-shim.js" }
+// with the platform packages listed in `optionalDependencies`.
+
+var childProcess = require('child_process');
+
+var target = process.platform + '-' + process.arch; // e.g. darwin-arm64, linux-x64
+var pkg = '@colbymchenry/codegraph-' + target;
+var launcher = process.platform === 'win32' ? 'bin/codegraph.cmd' : 'bin/codegraph';
+
+var binPath;
+try {
+  binPath = require.resolve(pkg + '/' + launcher);
+} catch (e) {
+  process.stderr.write(
+    'codegraph: no prebuilt bundle for ' + target + '.\n' +
+    'Expected the optional package ' + pkg + ' to be installed.\n' +
+    'Try reinstalling:  npm i -g @colbymchenry/codegraph\n' +
+    'Or use the standalone installer (no Node required):\n' +
+    '  curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh | sh\n'
+  );
+  process.exit(1);
+}
+
+var res = childProcess.spawnSync(binPath, process.argv.slice(2), { stdio: 'inherit' });
+if (res.error) {
+  process.stderr.write('codegraph: ' + res.error.message + '\n');
+  process.exit(1);
+}
+process.exit(res.status === null ? 1 : res.status);

+ 95 - 0
scripts/pack-npm.sh

@@ -0,0 +1,95 @@
+#!/usr/bin/env bash
+#
+# Assemble the npm thin-installer packages from built bundles (esbuild pattern).
+#
+# Produces, under release/npm/:
+#   codegraph-<target>/   one per built bundle — the vendored Node + app, tagged
+#                         with os/cpu so npm installs only the matching one.
+#   main/                 the @colbymchenry/codegraph shim package: a tiny bin
+#                         that execs the matching platform bundle, with every
+#                         platform package in optionalDependencies.
+#
+# The release pipeline then `npm publish`es each dir. This does NOT touch the
+# repo's package.json — the dev/from-source path keeps working; the *published*
+# main package's shape is generated here.
+#
+# Prereq: run build-bundle.sh for each target first (release/codegraph-*.tar.gz).
+# Usage:  scripts/pack-npm.sh [version]    (default: version from package.json)
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+VERSION="${1:-$(node -p "require('$ROOT/package.json').version")}"
+SCOPE="@colbymchenry"
+REL="$ROOT/release"
+NPM="$REL/npm"
+
+rm -rf "$NPM"
+mkdir -p "$NPM/main"
+
+shopt -s nullglob
+archives=("$REL"/codegraph-*.tar.gz "$REL"/codegraph-*.zip)
+[ ${#archives[@]} -gt 0 ] || { echo "[pack-npm] no bundles in $REL — run build-bundle.sh first" >&2; exit 1; }
+
+targets=()
+for archive in "${archives[@]}"; do
+  fname="$(basename "$archive")"
+  case "$fname" in
+    *.tar.gz) base="${fname%.tar.gz}" ;;   # codegraph-<target>
+    *.zip)    base="${fname%.zip}" ;;
+  esac
+  target="${base#codegraph-}"             # <target>, e.g. darwin-arm64 / win32-x64
+  os="${target%-*}"                       # darwin | linux | win32
+  arch="${target##*-}"                    # arm64 | x64
+  pkgdir="$NPM/$base"
+  mkdir -p "$pkgdir"
+  case "$fname" in
+    *.zip)
+      tmpx="$(mktemp -d)"
+      unzip -q "$archive" -d "$tmpx"
+      mv "$tmpx/codegraph-${target}"/* "$pkgdir"/
+      rm -rf "$tmpx"
+      nodefile="node.exe"
+      ;;
+    *)
+      tar -xzf "$archive" -C "$pkgdir" --strip-components=1
+      nodefile="node"
+      ;;
+  esac
+  VERSION="$VERSION" SCOPE="$SCOPE" TARGET="$target" OSV="$os" ARCHV="$arch" NODEFILE="$nodefile" \
+    node -e '
+      const fs=require("fs");
+      fs.writeFileSync(process.argv[1], JSON.stringify({
+        name: `${process.env.SCOPE}/codegraph-${process.env.TARGET}`,
+        version: process.env.VERSION,
+        description: `CodeGraph self-contained bundle for ${process.env.TARGET}`,
+        os: [process.env.OSV], cpu: [process.env.ARCHV],
+        files: [process.env.NODEFILE, "lib", "bin"],
+        license: "MIT"
+      }, null, 2) + "\n");
+    ' "$pkgdir/package.json"
+  targets+=("$target")
+  echo "[pack-npm] ${SCOPE}/codegraph-${target}@${VERSION}"
+done
+
+# Main shim package.
+cp "$ROOT/scripts/npm-shim.js" "$NPM/main/npm-shim.js"
+[ -f "$ROOT/README.md" ] && cp "$ROOT/README.md" "$NPM/main/README.md"
+VERSION="$VERSION" SCOPE="$SCOPE" TARGETS="${targets[*]}" \
+  node -e '
+    const fs=require("fs");
+    const opt={};
+    for (const t of process.env.TARGETS.split(/\s+/).filter(Boolean))
+      opt[`${process.env.SCOPE}/codegraph-${t}`]=process.env.VERSION;
+    fs.writeFileSync(process.argv[1], JSON.stringify({
+      name: `${process.env.SCOPE}/codegraph`,
+      version: process.env.VERSION,
+      description: "Local-first code intelligence for AI agents (MCP). Self-contained — bundles its own runtime.",
+      bin: { codegraph: "npm-shim.js" },
+      optionalDependencies: opt,
+      files: ["npm-shim.js","README.md"],
+      license: "MIT"
+    }, null, 2) + "\n");
+  ' "$NPM/main/package.json"
+
+echo "[pack-npm] ${SCOPE}/codegraph@${VERSION} (${#targets[@]} platform packages in optionalDependencies)"
+echo "[pack-npm] output: $NPM"

+ 24 - 8
src/bin/codegraph.ts

@@ -25,7 +25,7 @@ import { getCodeGraphDir, isInitialized } from '../directory';
 import { createShimmerProgress } from '../ui/shimmer-progress';
 import { getGlyphs } from '../ui/glyphs';
 
-import { buildNode25BlockBanner } from './node-version-check';
+import { buildNode25BlockBanner, buildNodeTooOldBanner, MIN_NODE_MAJOR } from './node-version-check';
 
 // Lazy-load heavy modules (CodeGraph, runInstaller) to keep CLI startup fast.
 async function loadCodeGraph(): Promise<typeof import('../index')> {
@@ -63,6 +63,16 @@ if (nodeMajor >= 25) {
   }
   // Override active — banner shown for visibility, continuing.
 }
+// Enforce the supported Node floor. `engines` in package.json only *warns* on
+// install (unless engine-strict), so hard-block here to actually keep users off
+// unsupported versions. Mirrors the 25+ block above. See package.json `engines`.
+if (nodeMajor < MIN_NODE_MAJOR) {
+  process.stderr.write(buildNodeTooOldBanner(nodeVersion) + '\n');
+  if (!process.env.CODEGRAPH_ALLOW_UNSAFE_NODE) {
+    process.exit(1);
+  }
+  // Override active — banner shown for visibility, continuing.
+}
 
 // Check if running with no arguments - run installer
 if (process.argv.length === 2) {
@@ -689,6 +699,7 @@ program
       const stats = cg.getStats();
       const changes = cg.getChangedFiles();
       const backend = cg.getBackend();
+      const journalMode = cg.getJournalMode();
 
       // JSON output mode
       if (options.json) {
@@ -700,6 +711,7 @@ program
           edgeCount: stats.edgeCount,
           dbSizeBytes: stats.dbSizeBytes,
           backend,
+          journalMode,
           nodesByKind: stats.nodesByKind,
           languages: Object.entries(stats.filesByLanguage).filter(([, count]) => count > 0).map(([lang]) => lang),
           pendingChanges: {
@@ -724,14 +736,18 @@ program
       console.log(`  Nodes:     ${formatNumber(stats.nodeCount)}`);
       console.log(`  Edges:     ${formatNumber(stats.edgeCount)}`);
       console.log(`  DB Size:   ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`);
-      // Surface the active SQLite backend so users can spot the silent
-      // WASM fallback (5-10x slower). better-sqlite3 is in
-      // `optionalDependencies`, so `npm install` succeeds without it
-      // when the native build fails.
-      const backendLabel = backend === 'native'
-        ? chalk.green('native')
-        : chalk.yellow(`wasm ${getGlyphs().dash} slower fallback; run \`npm rebuild better-sqlite3\``);
+      // Surface the active SQLite backend (node:sqlite — Node's built-in real
+      // SQLite, full WAL + FTS5, no native build).
+      const backendLabel = chalk.green(`node:sqlite ${getGlyphs().dash} built-in (full WAL)`);
       console.log(`  Backend:   ${backendLabel}`);
+      // Effective journal mode: 'wal' means concurrent reads never block on a
+      // writer; anything else means they can ("database is locked"). node:sqlite
+      // supports WAL everywhere, so a non-wal mode means the filesystem can't
+      // (network mounts, WSL2 /mnt). See issue #238.
+      const journalLabel = journalMode === 'wal'
+        ? chalk.green('wal')
+        : chalk.yellow(`${journalMode || 'unknown'} ${getGlyphs().dash} WAL inactive; reads can block on writes`);
+      console.log(`  Journal:   ${journalLabel}`);
       console.log();
 
       // Node breakdown

+ 37 - 0
src/bin/node-version-check.ts

@@ -37,3 +37,40 @@ export function buildNode25BlockBanner(nodeVersion: string): string {
     sep,
   ].join('\n');
 }
+
+/**
+ * Lowest supported Node.js major version. Matches the `engines` floor in
+ * package.json. Below this, CodeGraph relies on language features / native APIs
+ * that aren't present, and the combination is untested. `engines` alone only
+ * *warns* on install (unless the user set `engine-strict`), so the CLI bootstrap
+ * also hard-blocks here to actually enforce the floor.
+ */
+export const MIN_NODE_MAJOR = 20;
+
+/**
+ * Build the bordered banner shown when CodeGraph detects a Node.js major below
+ * {@link MIN_NODE_MAJOR}. Pinned via unit test so the recovery commands and the
+ * override env var can't be silently stripped by future edits.
+ *
+ * Uses ASCII glyphs to stay readable on Windows OEM-codepage consoles
+ * (see ../ui/glyphs.ts for the rationale).
+ */
+export function buildNodeTooOldBanner(nodeVersion: string): string {
+  const sep = '-'.repeat(72);
+  return [
+    sep,
+    `[CodeGraph] Unsupported Node.js version: ${nodeVersion}`,
+    sep,
+    `CodeGraph requires Node.js ${MIN_NODE_MAJOR} or newer. Older versions lack`,
+    'language features and native APIs CodeGraph depends on, and are not',
+    'tested or supported.',
+    '',
+    'Fix: install Node.js 22 LTS:',
+    '  nvm install 22 && nvm use 22                          # nvm',
+    '  brew install node@22 && brew link --overwrite --force node@22  # Homebrew',
+    '',
+    'To override (NOT recommended - unsupported):',
+    '  CODEGRAPH_ALLOW_UNSAFE_NODE=1 codegraph ...',
+    sep,
+  ].join('\n');
+}

+ 46 - 23
src/db/index.ts

@@ -10,7 +10,31 @@ import * as path from 'path';
 import { SchemaVersion } from '../types';
 import { runMigrations, getCurrentVersion, CURRENT_SCHEMA_VERSION } from './migrations';
 
-export { SqliteDatabase, SqliteBackend, WASM_FALLBACK_FIX_RECIPE } from './sqlite-adapter';
+export { SqliteDatabase, SqliteBackend } from './sqlite-adapter';
+
+/**
+ * Apply connection-level PRAGMAs. Shared by `initialize` and `open` so the two
+ * paths can't drift.
+ *
+ * `busy_timeout` is set FIRST, before any pragma that might touch the database
+ * file (notably `journal_mode`). If another process holds a write lock at open
+ * time, the later pragmas — and the connection's first query — then wait out
+ * the lock instead of throwing "database is locked" immediately. See issue #238.
+ *
+ * The 5s window (was 120s) rides out a normal incremental sync; the old
+ * 2-minute wait presented as a frozen, hung agent. With WAL, reads never block
+ * on a writer, so this timeout only governs cross-process write contention
+ * (e.g. the git-hook `codegraph sync` running while the MCP server writes).
+ */
+function configureConnection(db: SqliteDatabase): void {
+  db.pragma('busy_timeout = 5000');      // MUST be first — see above
+  db.pragma('foreign_keys = ON');
+  db.pragma('journal_mode = WAL');       // node:sqlite supports WAL on every platform
+  db.pragma('synchronous = NORMAL');     // safe with WAL mode
+  db.pragma('cache_size = -64000');      // 64 MB page cache
+  db.pragma('temp_store = MEMORY');      // temp tables in memory
+  db.pragma('mmap_size = 268435456');    // 256 MB memory-mapped I/O
+}
 
 /**
  * Database connection wrapper with lifecycle management
@@ -39,17 +63,7 @@ export class DatabaseConnection {
     // Create and configure database
     const { db, backend } = createDatabase(dbPath);
 
-    // Enable foreign keys and WAL mode for better performance
-    db.pragma('foreign_keys = ON');
-    db.pragma('journal_mode = WAL');
-    // Wait up to 2 minutes if database is locked by another process
-    // (indexing operations can hold locks for extended periods)
-    db.pragma('busy_timeout = 120000');
-    // Performance tuning
-    db.pragma('synchronous = NORMAL');     // Safe with WAL mode
-    db.pragma('cache_size = -64000');      // 64 MB page cache
-    db.pragma('temp_store = MEMORY');      // Temp tables in memory
-    db.pragma('mmap_size = 268435456');    // 256 MB memory-mapped I/O
+    configureConnection(db);
 
     // Run schema initialization
     const schemaPath = path.join(__dirname, 'schema.sql');
@@ -77,17 +91,7 @@ export class DatabaseConnection {
 
     const { db, backend } = createDatabase(dbPath);
 
-    // Enable foreign keys and WAL mode
-    db.pragma('foreign_keys = ON');
-    db.pragma('journal_mode = WAL');
-    // Wait up to 2 minutes if database is locked by another process
-    // (indexing operations can hold locks for extended periods)
-    db.pragma('busy_timeout = 120000');
-    // Performance tuning
-    db.pragma('synchronous = NORMAL');
-    db.pragma('cache_size = -64000');
-    db.pragma('temp_store = MEMORY');
-    db.pragma('mmap_size = 268435456');
+    configureConnection(db);
 
     // Check and run migrations if needed
     const conn = new DatabaseConnection(db, dbPath, backend);
@@ -123,6 +127,25 @@ export class DatabaseConnection {
     return this.dbPath;
   }
 
+  /**
+   * The journal mode actually in effect (e.g. 'wal', 'delete').
+   *
+   * SQLite silently keeps the prior mode if WAL can't be enabled — e.g. on
+   * filesystems without shared-memory support (some network/virtualized mounts,
+   * WSL2 /mnt), and always on the wasm backend. So the effective mode can differ
+   * from what `configureConnection` requested. Surfaced in `codegraph status` so
+   * a "database is locked" report is triageable: 'wal' ⇒ readers never block on a
+   * writer; anything else ⇒ they can. See issue #238.
+   */
+  getJournalMode(): string {
+    const raw = this.db.pragma('journal_mode');
+    const row = Array.isArray(raw) ? raw[0] : raw;
+    const mode = row && typeof row === 'object'
+      ? (row as Record<string, unknown>).journal_mode
+      : row;
+    return String(mode ?? '').toLowerCase();
+  }
+
   /**
    * Get current schema version
    */

+ 56 - 184
src/db/sqlite-adapter.ts

@@ -1,8 +1,13 @@
 /**
  * SQLite Adapter
  *
- * Provides a unified interface over better-sqlite3 (native) and
- * node-sqlite3-wasm (WASM fallback) for universal cross-platform support.
+ * Thin wrapper over Node's built-in `node:sqlite` (`DatabaseSync`), exposed
+ * through a small better-sqlite3-shaped interface so the rest of the codebase
+ * is storage-agnostic.
+ *
+ * CodeGraph ships with a bundled Node runtime, so `node:sqlite` (real SQLite,
+ * with WAL + FTS5) is always available — there is no native build step and no
+ * wasm fallback. When run from source instead, it requires Node >= 22.5.
  */
 
 export interface SqliteStatement {
@@ -14,123 +19,34 @@ export interface SqliteStatement {
 export interface SqliteDatabase {
   prepare(sql: string): SqliteStatement;
   exec(sql: string): void;
-  pragma(str: string): any;
+  pragma(str: string, options?: { simple?: boolean }): any;
   transaction<T>(fn: (...args: any[]) => T): (...args: any[]) => T;
   close(): void;
   readonly open: boolean;
 }
 
-export type SqliteBackend = 'native' | 'wasm';
-
 /**
- * One-line summary of the recovery steps shown when WASM fallback is
- * active. Single source of truth so the recipe can't drift between the
- * stderr banner and the MCP status formatter.
+ * The active SQLite backend. Only one now (`node:sqlite`); kept as a named type
+ * so `codegraph status` and the per-instance reporting have a stable shape.
  */
-export const WASM_FALLBACK_FIX_RECIPE =
-  '`xcode-select --install` (macOS) or `apt install build-essential` (Debian/Ubuntu), ' +
-  'then `npm rebuild better-sqlite3`, or `npm install better-sqlite3 --save` to force-include it.';
+export type SqliteBackend = 'node-sqlite';
 
 /**
- * Multi-line banner shown to stderr when `createDatabase` falls back to
- * WASM. Replaces a one-line `console.warn` that MCP transports (which
- * take stdout for the protocol) typically swallow, leaving users on a
- * 5-10x slower backend with no signal.
+ * Wraps Node's built-in `node:sqlite` (`DatabaseSync`) to match the
+ * better-sqlite3 interface the rest of the code expects.
  *
- * Exported for unit testing — pinning the recipe content prevents
- * future edits from silently stripping the recovery commands.
+ * node:sqlite is real SQLite compiled into Node, so it supports WAL, FTS5,
+ * mmap, and `@named` params natively — the only shims needed are the
+ * better-sqlite3 conveniences node:sqlite omits: a `.pragma()` helper, a
+ * `.transaction()` helper, and `open` (node:sqlite exposes `isOpen`).
  */
-export function buildWasmFallbackBanner(nativeError?: string): string {
-  const sep = '─'.repeat(72);
-  const lines = [
-    sep,
-    '[CodeGraph] WASM SQLite fallback active (better-sqlite3 unavailable)',
-    sep,
-    'Indexing and sync will be 5-10x slower than the native backend.',
-    '',
-    'Fix on macOS:',
-    '  xcode-select --install        # install C build tools',
-    '  npm rebuild better-sqlite3    # rebuild native binding for current Node',
-    '',
-    'Fix on Linux:',
-    '  sudo apt install build-essential python3 make    # Debian/Ubuntu',
-    '  # or: sudo yum groupinstall "Development Tools"  # RHEL/Fedora',
-    '  npm rebuild better-sqlite3',
-    '',
-    'Or force-include as a hard dependency on any platform:',
-    '  npm install better-sqlite3 --save',
-    '',
-    'Verify after fix: `codegraph status` should show `Backend: native`.',
-  ];
-  if (nativeError) {
-    lines.push('', `Native load error: ${nativeError}`);
-  }
-  lines.push(sep);
-  return lines.join('\n');
-}
-
-/**
- * Translate @named parameters (better-sqlite3 style) to positional ? params
- * for node-sqlite3-wasm, which only supports positional binding.
- *
- * Returns the rewritten SQL and an ordered list of parameter names.
- * If no named params are found, returns null for paramOrder (positional mode).
- */
-function translateNamedParams(sql: string): { sql: string; paramOrder: string[] | null } {
-  const paramOrder: string[] = [];
-  const rewritten = sql.replace(/@(\w+)/g, (_match, name: string) => {
-    paramOrder.push(name);
-    return '?';
-  });
-  if (paramOrder.length === 0) {
-    return { sql, paramOrder: null };
-  }
-  return { sql: rewritten, paramOrder };
-}
-
-/**
- * Convert better-sqlite3-style params to a positional array for node-sqlite3-wasm.
- *
- * Handles three calling conventions:
- * - Named object: run({ id: '1', name: 'a' }) → positional array via paramOrder
- * - Positional args: run('a', 'b') → ['a', 'b']
- * - No args: run() → undefined
- */
-function resolveParams(params: any[], paramOrder: string[] | null): any {
-  if (params.length === 0) return undefined;
-
-  // If paramOrder exists and first arg is a plain object, do named→positional translation
-  if (paramOrder && params.length === 1 && params[0] !== null && typeof params[0] === 'object' && !Array.isArray(params[0]) && !(params[0] instanceof Buffer) && !(params[0] instanceof Uint8Array)) {
-    const obj = params[0];
-    return paramOrder.map(name => obj[name]);
-  }
-
-  // Positional: single value or already an array
-  if (params.length === 1) return params[0];
-  return params;
-}
-
-/**
- * Wraps node-sqlite3-wasm to match the better-sqlite3 interface.
- *
- * Key differences handled:
- * - better-sqlite3 uses @named params; node-sqlite3-wasm uses positional ? only
- * - better-sqlite3 uses variadic args: stmt.run(a, b, c)
- * - node-sqlite3-wasm uses a single array/object: stmt.run([a, b, c])
- * - node-sqlite3-wasm has `isOpen` instead of `open`
- * - node-sqlite3-wasm doesn't have a `pragma()` method
- * - node-sqlite3-wasm doesn't have a `transaction()` method
- */
-class WasmDatabaseAdapter implements SqliteDatabase {
+class NodeSqliteAdapter implements SqliteDatabase {
   private _db: any;
-  // Track raw WASM statements so we can finalize them on close.
-  // node-sqlite3-wasm won't release its file lock if statements are left open.
-  private _openStmts = new Set<any>();
 
   constructor(dbPath: string) {
     // eslint-disable-next-line @typescript-eslint/no-require-imports
-    const { Database } = require('node-sqlite3-wasm');
-    this._db = new Database(dbPath);
+    const { DatabaseSync } = require('node:sqlite');
+    this._db = new DatabaseSync(dbPath);
   }
 
   get open(): boolean {
@@ -138,25 +54,23 @@ class WasmDatabaseAdapter implements SqliteDatabase {
   }
 
   prepare(sql: string): SqliteStatement {
-    const { sql: rewrittenSql, paramOrder } = translateNamedParams(sql);
-    const stmt = this._db.prepare(rewrittenSql);
-    this._openStmts.add(stmt);
+    // node:sqlite matches better-sqlite3's calling convention (variadic
+    // positional args, or a single object for @named params), so params forward
+    // through unchanged.
+    const stmt = this._db.prepare(sql);
     return {
       run(...params: any[]) {
-        const resolved = resolveParams(params, paramOrder);
-        const result = resolved !== undefined ? stmt.run(resolved) : stmt.run();
+        const r = stmt.run(...params);
         return {
-          changes: result?.changes ?? 0,
-          lastInsertRowid: result?.lastInsertRowid ?? 0,
+          changes: Number(r?.changes ?? 0),
+          lastInsertRowid: r?.lastInsertRowid ?? 0,
         };
       },
       get(...params: any[]) {
-        const resolved = resolveParams(params, paramOrder);
-        return resolved !== undefined ? stmt.get(resolved) : stmt.get();
+        return stmt.get(...params);
       },
       all(...params: any[]) {
-        const resolved = resolveParams(params, paramOrder);
-        return resolved !== undefined ? stmt.all(resolved) : stmt.all();
+        return stmt.all(...params);
       },
     };
   }
@@ -165,41 +79,21 @@ class WasmDatabaseAdapter implements SqliteDatabase {
     this._db.exec(sql);
   }
 
-  pragma(str: string): any {
+  pragma(str: string, options?: { simple?: boolean }): any {
     const trimmed = str.trim();
-
-    // Write pragma: "key = value"
+    // Write pragma ("key = value"): node:sqlite is real SQLite, so every pragma
+    // (WAL, mmap, synchronous, …) applies as-is.
     if (trimmed.includes('=')) {
-      const eqIdx = trimmed.indexOf('=');
-      const key = trimmed.substring(0, eqIdx).trim();
-      const value = trimmed.substring(eqIdx + 1).trim();
-
-      // WAL is not supported in WASM SQLite — use DELETE journal mode
-      if (key === 'journal_mode' && value.toUpperCase() === 'WAL') {
-        this._db.exec('PRAGMA journal_mode = DELETE');
-        return;
-      }
-
-      // mmap is not available in WASM — silently skip
-      if (key === 'mmap_size') {
-        return;
-      }
-
-      // synchronous = NORMAL is unsafe without WAL — use FULL
-      if (key === 'synchronous' && value.toUpperCase() === 'NORMAL') {
-        this._db.exec('PRAGMA synchronous = FULL');
-        return;
-      }
-
-      this._db.exec(`PRAGMA ${key} = ${value}`);
+      this._db.exec(`PRAGMA ${trimmed}`);
       return;
     }
-
-    // Read pragma: "key" — return the value
-    const stmt = this._db.prepare(`PRAGMA ${trimmed}`);
-    const result = stmt.get();
-    stmt.finalize();
-    return result;
+    // Read pragma. Default: the row object (e.g. { journal_mode: 'wal' }).
+    // `{ simple: true }` returns just the single column value, like better-sqlite3.
+    const row = this._db.prepare(`PRAGMA ${trimmed}`).get();
+    if (options?.simple) {
+      return row && typeof row === 'object' ? Object.values(row)[0] : row;
+    }
+    return row;
   }
 
   transaction<T>(fn: (...args: any[]) => T): (...args: any[]) => T {
@@ -217,51 +111,29 @@ class WasmDatabaseAdapter implements SqliteDatabase {
   }
 
   close(): void {
-    // Finalize all tracked statements before closing.
-    // node-sqlite3-wasm won't release its directory-based file lock
-    // if any prepared statements remain open.
-    for (const stmt of this._openStmts) {
-      try { stmt.finalize(); } catch { /* already finalized */ }
-    }
-    this._openStmts.clear();
-    this._db.close();
+    // node:sqlite's DatabaseSync.close() throws if already closed; make it
+    // idempotent to match better-sqlite3 (callers may close more than once).
+    if (this._db.isOpen) this._db.close();
   }
 }
 
 /**
- * Create a database connection. Tries native better-sqlite3 first,
- * falls back to node-sqlite3-wasm. Returns the active backend
- * alongside the db so each `DatabaseConnection` can report its own
- * backend per-instance — MCP can open multiple project DBs in one
- * process (`tools.ts` getCodeGraph cache), so a process-global would
- * race / overwrite.
+ * Create a database connection backed by `node:sqlite`.
+ *
+ * Returns the active backend alongside the db so each `DatabaseConnection` can
+ * report it per-instance — MCP can open multiple project DBs in one process, so
+ * a process-global would race.
  */
 export function createDatabase(dbPath: string): { db: SqliteDatabase; backend: SqliteBackend } {
-  let nativeError: string | undefined;
-  let wasmError: string | undefined;
-
-  // Try native better-sqlite3 first
-  try {
-    // eslint-disable-next-line @typescript-eslint/no-require-imports
-    const Database = require('better-sqlite3');
-    const db = new Database(dbPath);
-    return { db: db as SqliteDatabase, backend: 'native' };
-  } catch (error) {
-    nativeError = error instanceof Error ? error.message : String(error);
-  }
-
-  // Fall back to WASM
   try {
-    const db = new WasmDatabaseAdapter(dbPath);
-    console.warn(buildWasmFallbackBanner(nativeError));
-    return { db, backend: 'wasm' };
+    return { db: new NodeSqliteAdapter(dbPath), backend: 'node-sqlite' };
   } catch (error) {
-    wasmError = error instanceof Error ? error.message : String(error);
+    const msg = error instanceof Error ? error.message : String(error);
+    throw new Error(
+      'Failed to open SQLite via the built-in node:sqlite module.\n' +
+      'CodeGraph requires node:sqlite (Node.js 22.5+). Install the self-contained\n' +
+      'CodeGraph release (it bundles a compatible Node), or run on Node 22.5+.\n' +
+      `Underlying error: ${msg}`
+    );
   }
-
-  throw new Error(
-    `Failed to load any SQLite backend.\n` +
-    `  Native (better-sqlite3): ${nativeError}\n` +
-    `  WASM (node-sqlite3-wasm): ${wasmError}`
-  );
 }

+ 13 - 4
src/index.ts

@@ -613,15 +613,24 @@ export class CodeGraph {
   }
 
   /**
-   * Active SQLite backend for this project's connection. `wasm` means
-   * the native better-sqlite3 install failed and the WASM fallback is
-   * serving requests at 5-10x the latency. Surfaced via `codegraph
-   * status` and the `codegraph_status` MCP tool.
+   * Active SQLite backend for this project's connection (`node-sqlite` — Node's
+   * built-in real-SQLite module). Surfaced via `codegraph status` and the
+   * `codegraph_status` MCP tool alongside the effective journal mode.
    */
   getBackend(): import('./db').SqliteBackend {
     return this.db.getBackend();
   }
 
+  /**
+   * The journal mode actually in effect ('wal', 'delete', …). 'wal' means
+   * readers never block on a concurrent writer; anything else means they can,
+   * which is the precondition for the "database is locked" failures in issue
+   * #238. Surfaced via `codegraph status` and the `codegraph_status` MCP tool.
+   */
+  getJournalMode(): string {
+    return this.db.getJournalMode();
+  }
+
   // ===========================================================================
   // Node Operations
   // ===========================================================================

+ 24 - 9
src/mcp/tools.ts

@@ -11,7 +11,6 @@ import { writeFileSync, readFileSync, existsSync } from 'fs';
 import { clamp, validatePathWithinRoot } from '../utils';
 import { tmpdir } from 'os';
 import { join } from 'path';
-import { WASM_FALLBACK_FIX_RECIPE } from '../db';
 
 /** Maximum output length to prevent context bloat (characters) */
 const MAX_OUTPUT_LENGTH = 15000;
@@ -542,6 +541,17 @@ export class ToolHandler {
       throw new Error(`CodeGraph not initialized in ${projectPath}. Run 'codegraph init' in that project first.`);
     }
 
+    // If the path resolves to the default project, reuse the already-open
+    // default instance rather than opening a SECOND connection to the same DB.
+    // A duplicate connection serializes reads against the watcher's auto-sync
+    // writes; on the wasm backend (no WAL) that surfaces as intermittent
+    // "database is locked" on concurrent tool calls. See issue #238. Deliberately
+    // not cached under projectPath — the server owns and closes the default
+    // instance, so routing it through projectCache.closeAll() would double-close it.
+    if (this.cg && this.cg.getProjectRoot() === resolvedRoot) {
+      return this.cg;
+    }
+
     // Check if we already have this resolved root cached (different path, same project)
     if (this.projectCache.has(resolvedRoot)) {
       const cg = this.projectCache.get(resolvedRoot)!;
@@ -1321,16 +1331,21 @@ export class ToolHandler {
       `**Database size:** ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`,
     ];
 
-    // Surface the active SQLite backend. Without this, users on the
-    // silent WASM fallback (better-sqlite3 install failed) see "slow"
-    // indexing and DB-lock errors with no signal of why.
-    const backend = cg.getBackend();
-    if (backend === 'native') {
-      lines.push(`**Backend:** native (better-sqlite3)`);
+    // Surface the active SQLite backend (node:sqlite, Node's built-in real
+    // SQLite — full WAL + FTS5, no native build).
+    lines.push(`**Backend:** node:sqlite (Node built-in) — full WAL + FTS5`);
+
+    // Effective journal mode. 'wal' ⇒ concurrent reads never block on a writer;
+    // anything else ⇒ they can ("database is locked"). node:sqlite supports WAL
+    // everywhere, so a non-wal mode means the filesystem can't (network/
+    // virtualized mounts, WSL2 /mnt). See issue #238.
+    const journalMode = cg.getJournalMode();
+    if (journalMode === 'wal') {
+      lines.push(`**Journal mode:** wal (concurrent reads safe)`);
     } else {
       lines.push(
-        `**Backend:** ⚠ wasm (better-sqlite3 unavailable) — ` +
-        `5-10x slower than native. Fix: ${WASM_FALLBACK_FIX_RECIPE}`
+        `**Journal mode:** ⚠ ${journalMode || 'unknown'} — WAL not active, so reads ` +
+        `can block on a concurrent write (WAL appears unsupported on this filesystem)`
       );
     }