From 18d56bb94bef6b32bd74f2365d6f856446c792f9 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Sat, 10 Oct 2026 16:58:11 -0600 Subject: [PATCH 1/3] test: make the Bun test run pass by probing capabilities Bun differs from Node in ways the suite depended on. Tests that need a patched builtin to reach the library (syncBuiltinESMExports is a no-op in Bun) or an emptied PATH to block a spawn now skip through capabilities measured at collection time, with guard tests that fail if a capability is missing anywhere but Bun. grammar-load-failure mocks by absolute path (oven-sh/bun#43685), worker-teardown asserts Bun's behavior when gc is unavailable, and the mcp-daemon PID-reuse test compares the indexed content instead of file bytes, since a checkpoint can fold WAL frames back into the main file without changing any row. Bun differences reported upstream: oven-sh/bun#44947 (readdirSync order) and oven-sh/bun#44948 (empty PATH). --- .../codegraph-build-validation/SKILL.md | 2 +- README.md | 2 +- __tests__/grammar-load-failure.test.ts | 5 ++- __tests__/helpers/runtime-capabilities.ts | 42 +++++++++++++++++++ __tests__/mcp-daemon.test.ts | 25 ++++++++++- __tests__/mcp-nested-repo-projectpath.test.ts | 3 +- __tests__/runtime-capabilities.test.ts | 15 +++++++ __tests__/runtime-control.test.ts | 9 ++-- __tests__/sync-resynthesis.test.ts | 3 +- __tests__/worker-teardown.test.ts | 13 ++++-- __tests__/writer-lock.test.ts | 3 +- src/directory.ts | 2 +- 12 files changed, 108 insertions(+), 16 deletions(-) create mode 100644 __tests__/helpers/runtime-capabilities.ts create mode 100644 __tests__/runtime-capabilities.test.ts diff --git a/.claude/skills/codegraph-build-validation/SKILL.md b/.claude/skills/codegraph-build-validation/SKILL.md index 2d9bdde5fd..ee041bf1d0 100644 --- a/.claude/skills/codegraph-build-validation/SKILL.md +++ b/.claude/skills/codegraph-build-validation/SKILL.md @@ -25,7 +25,7 @@ npx vitest run __tests__/installer-targets.test.ts npx vitest run __tests__/extraction.test.ts -t "TypeScript" ``` -Under Bun the suite runs via `__tests__/bun-homedir.setup.ts` (an `os.homedir` shim for oven-sh/bun#29244) plus three test-file adaptations linked to their upstream issues (#42891, #42893, #25498). `npm run test:bun` still fails 19 tests that pass on Node: tests that replace modules or `fs` functions (Bun's `syncBuiltinESMExports` is a no-op and a `vi.mock` of a module loaded through the lazy `require('./extraction')` appears not to apply), one that empties `PATH` (Bun still finds `git`), and a V8-only `gc` test. Do not read those as regressions; a new Bun-only failure is one outside that list. +Under Bun the suite runs via `__tests__/bun-homedir.setup.ts` (an `os.homedir` shim for oven-sh/bun#29244) plus three test-file adaptations linked to their upstream issues (#42891, #42893, #25498). `npm run test:bun` skips tests that need a runtime capability Bun lacks (patching a builtin so the library sees it, an emptied `PATH` blocking a spawn); `__tests__/helpers/runtime-capabilities.ts` measures each one at run time and `__tests__/runtime-capabilities.test.ts` fails if it goes missing anywhere but Bun. Gate a new test of that kind with the helper rather than `process.versions.bun`. Under Bun, spell a `vi.mock` path as an absolute path (`${__dirname}/…`): a relative one resolves against the project root (oven-sh/bun#43685). `copy-assets` (called from `build`) copies `src/db/schema.sql` into `dist/`. **Any new SQL asset must be copied or it won't ship.** diff --git a/README.md b/README.md index 66e0c5cdac..ba5d29e815 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ Both builds rebuild the links inferred from events, callbacks and function point | `codegraph status` | 173 ms | 117 ms | 86 ms | | `codegraph explore ""` | 224 ms | 121 ms | 99 ms | -**Node or Bun.** The fork produces the same node and edge counts on both, and the full test suite passed on Node.js 24.21.0, Node.js 26.10.0 and Bun 1.4.2 at `48903f5` (`npm run test:bun`; one test is skipped under Bun for [oven-sh/bun#42891](https://github.com/oven-sh/bun/issues/42891)). Since then the suite has grown: at `2bf24ef4` plus the directory-order fix, `npm run test:bun` fails 19 of 8,780 tests that pass on Node (writer-lock, watcher-startup and grammar-load-failure tests that replace modules or `fs` functions, a test that empties `PATH`, and a V8-only garbage-collection test); none is a known product defect. On Bun, peak index memory is 8–27% lower than on Node, the idle MCP server holds 37–41% less memory, and commands start faster. Index and sync times range from 6% slower to 15% faster than Node's. Bun has two costs. Eight concurrent explores on n8n take 12% longer; contention between worker threads reading through Bun's SQLite is the suspected cause ([oven-sh/bun#44084](https://github.com/oven-sh/bun/issues/44084), [#44187](https://github.com/oven-sh/bun/issues/44187)). Idle CPU is two to four times Node's, though still under 1% of one core. +**Node or Bun.** The fork produces the same node and edge counts on both, and the full test suite passed on Node.js 24.21.0, Node.js 26.10.0 and Bun 1.4.2 at `48903f5` (`npm run test:bun`; one test is skipped under Bun for [oven-sh/bun#42891](https://github.com/oven-sh/bun/issues/42891)). Since then the suite has grown, and the Bun run skips 14 more tests that need what Bun does not provide: patching a builtin so the library sees it (`syncBuiltinESMExports` is a no-op, [oven-sh/bun#42616](https://github.com/oven-sh/bun/pull/42616)) and an emptied `PATH` blocking a spawn ([oven-sh/bun#44948](https://github.com/oven-sh/bun/issues/44948)). Each skip is measured at run time, so the test returns when Bun supports it. On Bun, peak index memory is 8–27% lower than on Node, the idle MCP server holds 37–41% less memory, and commands start faster. Index and sync times range from 6% slower to 15% faster than Node's. Bun has two costs. Eight concurrent explores on n8n take 12% longer; contention between worker threads reading through Bun's SQLite is the suspected cause ([oven-sh/bun#44084](https://github.com/oven-sh/bun/issues/44084), [#44187](https://github.com/oven-sh/bun/issues/44187)). Idle CPU is two to four times Node's, though still under 1% of one core. Measured separately: diff --git a/__tests__/grammar-load-failure.test.ts b/__tests__/grammar-load-failure.test.ts index fba49c671f..bddca6301d 100644 --- a/__tests__/grammar-load-failure.test.ts +++ b/__tests__/grammar-load-failure.test.ts @@ -23,7 +23,10 @@ import type { Language } from '../src/types'; const { failing, throwing } = vi.hoisted(() => ({ failing: new Set(), throwing: new Set() })); -vi.mock('../src/extraction/parse-tree', async (importOriginal) => { +// Spelled as an absolute path: vitest finds the test file that called `vi.mock` from a +// stack frame reading `at Object.mock`, which Bun prints as `at mock`, so under Bun a +// relative path resolves against the project root (oven-sh/bun#43685). +vi.mock(`${__dirname}/../src/extraction/parse-tree`, async (importOriginal) => { const actual = await importOriginal(); const { KernelUnavailableError } = await import('../src/extraction/kernel/loader'); return { diff --git a/__tests__/helpers/runtime-capabilities.ts b/__tests__/helpers/runtime-capabilities.ts new file mode 100644 index 0000000000..559ca3307d --- /dev/null +++ b/__tests__/helpers/runtime-capabilities.ts @@ -0,0 +1,42 @@ +/** + * Behaviors the engine suites rely on that Bun's Node compatibility does not + * provide. Each is measured, not assumed from the runtime name, so a test + * comes back on its own once the runtime supports it; `runtime-capabilities.test.ts` + * fails if a capability goes missing anywhere but Bun. + */ +import * as fsNamespace from 'node:fs'; +import fsDefault from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { syncBuiltinESMExports } from 'node:module'; + +/** + * Replacing a method on a builtin's CommonJS object and calling + * `syncBuiltinESMExports()` reaches code that imports the builtin by name or as + * a namespace. Bun's `syncBuiltinESMExports` is a no-op (a fix is open as + * oven-sh/bun#42616), so spies on `fs` and `child_process` never reach the + * library under test there. + */ +export const builtinPatchingReachesImporters = (() => { + const original = fsDefault.accessSync; + const stub = (() => undefined) as typeof fsDefault.accessSync; + fsDefault.accessSync = stub; + try { + syncBuiltinESMExports(); + return fsNamespace.accessSync === stub; + } finally { + fsDefault.accessSync = original; + syncBuiltinESMExports(); + } +})(); + +/** A child spawned by bare name fails with ENOENT once `process.env.PATH` is emptied. Bun treats an empty PATH as unset and still finds the binary (oven-sh/bun#44948). */ +export const emptyPathBlocksSpawn = (() => { + const saved = process.env.PATH; + process.env.PATH = ''; + try { + return (spawnSync('git', ['--version']).error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT'; + } finally { + if (saved === undefined) delete process.env.PATH; + else process.env.PATH = saved; + } +})(); diff --git a/__tests__/mcp-daemon.test.ts b/__tests__/mcp-daemon.test.ts index 03600002a5..c390831e44 100644 --- a/__tests__/mcp-daemon.test.ts +++ b/__tests__/mcp-daemon.test.ts @@ -45,6 +45,9 @@ import { CodeGraphPackageVersion } from '../src/mcp/version'; import { once } from 'events'; import { recordSpawns, removeSpawnLog, settleLosingCandidates } from './daemon-candidates'; +// Vite strips the `node:` prefix from an import of this builtin; require keeps it. +const { DatabaseSync } = require('node:sqlite') as typeof import('node:sqlite'); + const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); interface SpawnedServer { @@ -53,6 +56,24 @@ interface SpawnedServer { stderr: string[]; } +/** + * The indexed content of a project's database, read through a read-only + * connection. Comparing it instead of the raw file bytes asks "did anything + * change the index?" without failing when a checkpoint folds WAL frames left + * by a killed writer back into the main file (Bun does that at times; the + * rows are identical). + */ +function readIndexedContent(root: string): string { + const db = new DatabaseSync(path.join(root, '.codegraph', 'codegraph.db'), { readOnly: true }); + try { + const files = db.prepare('SELECT path, content_hash, node_count FROM files ORDER BY path').all(); + const nodes = db.prepare('SELECT count(*) AS n FROM nodes').get(); + return JSON.stringify({ files, nodes }); + } finally { + db.close(); + } +} + function spawnServer(cwd: string, env: NodeJS.ProcessEnv = {}, args: string[] = []): SpawnedServer { // Record the daemon candidates this launcher spawns, for the teardown. const recorder = recordSpawns(cwd); @@ -637,7 +658,7 @@ describe('Shared MCP daemon (issue #411)', () => { // Make the index stale by changing the source, without opening a new // SQLite writer against the database of the daemon we just killed. - const before = fs.readFileSync(path.join(realRoot, '.codegraph', 'codegraph.db')); + const before = readIndexedContent(realRoot); fs.writeFileSync(path.join(realRoot, 'app.ts'), 'export function changedSymbol() {}\n'); const second = spawnServer(tempDir, env); servers.push(second); @@ -675,7 +696,7 @@ describe('Shared MCP daemon (issue #411)', () => { expect(fs.readFileSync(daemonPath, 'utf8')).toBe(staleDaemonLock); second.child.stdin.end(); await waitFor(() => second.child.exitCode !== null, 5000); - expect(fs.readFileSync(path.join(realRoot, '.codegraph', 'codegraph.db'))).toEqual(before); + expect(readIndexedContent(realRoot)).toBe(before); expect(fs.readFileSync(writerPath, 'utf8')).toBe(staleWriterLock); }, 50000); diff --git a/__tests__/mcp-nested-repo-projectpath.test.ts b/__tests__/mcp-nested-repo-projectpath.test.ts index 01a380481d..ba5c825584 100644 --- a/__tests__/mcp-nested-repo-projectpath.test.ts +++ b/__tests__/mcp-nested-repo-projectpath.test.ts @@ -19,6 +19,7 @@ import * as os from 'os'; import * as path from 'path'; import CodeGraph from '../src/index'; import { ToolHandler, __setLoadCodeGraphForTests } from '../src/mcp/tools'; +import { emptyPathBlocksSpawn } from './helpers/runtime-capabilities'; const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); @@ -183,7 +184,7 @@ describe('projectPath inside a nested git repository the ancestor index excludes expect(text(res)).toContain('nested-only.ts'); }); - it('fails open to the ancestor index when git cannot be run', async () => { + it.skipIf(!emptyPathBlocksSpawn)('fails open to the ancestor index when git cannot be run', async () => { handler = new ToolHandler(cg); const savedPath = process.env.PATH; process.env.PATH = ''; diff --git a/__tests__/runtime-capabilities.test.ts b/__tests__/runtime-capabilities.test.ts new file mode 100644 index 0000000000..ed225ea649 --- /dev/null +++ b/__tests__/runtime-capabilities.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { builtinPatchingReachesImporters, emptyPathBlocksSpawn } from './helpers/runtime-capabilities'; + +// Tests that need these capabilities skip themselves where they are missing. This +// keeps that skip to Bun: on Node a missing capability would otherwise silently +// turn a whole group of tests off. +describe('runtime capabilities the suites skip on', () => { + it('lets a patched builtin reach its importers, except on Bun', () => { + expect(builtinPatchingReachesImporters || !!process.versions.bun).toBe(true); + }); + + it('lets an emptied PATH block a spawn by name, except on Bun', () => { + expect(emptyPathBlocksSpawn || !!process.versions.bun).toBe(true); + }); +}); diff --git a/__tests__/runtime-control.test.ts b/__tests__/runtime-control.test.ts index 45776a75db..5d2f4f6259 100644 --- a/__tests__/runtime-control.test.ts +++ b/__tests__/runtime-control.test.ts @@ -22,6 +22,7 @@ import * as daemonRegistry from '../src/mcp/daemon-registry'; import { getTelemetry } from '../src/telemetry'; import * as updateCheck from '../src/upgrade/update-check'; import { getWriterPidPath } from '../src/mcp/writer-lock'; +import { builtinPatchingReachesImporters } from './helpers/runtime-capabilities'; let root: string; let server: net.Server | null = null; @@ -362,7 +363,7 @@ describe('ordinary watcher startup', () => { }, ); - it('initializes an exact child index and refreshes the same daemon after a new edit', async () => { + it.skipIf(!builtinPatchingReachesImporters)('initializes an exact child index and refreshes the same daemon after a new edit', async () => { vi.stubEnv('CODEGRAPH_WATCH_DEBOUNCE_MS', '60000'); vi.stubEnv('CODEGRAPH_QUERY_POOL_SIZE', '0'); await initialize(); @@ -626,7 +627,7 @@ describe('ordinary watcher startup', () => { finally { reader.close(); } }); - it('converges racing ordinary starters on one real watcher without a promotion lease', async () => { + it.skipIf(!builtinPatchingReachesImporters)('converges racing ordinary starters on one real watcher without a promotion lease', async () => { await initialize(); const original = childProcess.spawn; const spawned: ChildProcess[] = []; @@ -647,7 +648,7 @@ describe('ordinary watcher startup', () => { await checkRuntimeReady(root, winner, 1000, { requireWatcher: true }); }); - it.each(['promotion', 'older', 'invalid-writer'].flatMap(event => [true, false].map(initialized => ({ event, initialized }))))( + it.skipIf(!builtinPatchingReachesImporters).each(['promotion', 'older', 'invalid-writer'].flatMap(event => [true, false].map(initialized => ({ event, initialized }))))( 'preserves a $event owner after precheck before election (initialized=$initialized)', async ({ event, initialized }) => { if (initialized) await initialize(); const original = childProcess.spawn; @@ -677,7 +678,7 @@ describe('ordinary watcher startup', () => { }, ); - it('times out with a disabled real watcher without terminating a possibly shared daemon', async () => { + it.skipIf(!builtinPatchingReachesImporters)('times out with a disabled real watcher without terminating a possibly shared daemon', async () => { await initialize(); const original = childProcess.spawn; spySpawn().mockImplementation(((command: string, args: string[], spawnOptions: childProcess.SpawnOptions) => { diff --git a/__tests__/sync-resynthesis.test.ts b/__tests__/sync-resynthesis.test.ts index b2f9d4b5af..4bc259c466 100644 --- a/__tests__/sync-resynthesis.test.ts +++ b/__tests__/sync-resynthesis.test.ts @@ -11,6 +11,7 @@ import * as path from 'path'; import * as os from 'os'; import { syncBuiltinESMExports } from 'node:module'; import CodeGraph from '../src/index'; +import { builtinPatchingReachesImporters } from './helpers/runtime-capabilities'; describe('synthesized edges on incremental sync', () => { let dir: string; @@ -151,7 +152,7 @@ describe('synthesized edges on incremental sync', () => { expect(callees('loadRepo')).toContain('GET https://api.github.com/repos/${…}'); }); - it('rejects an unreadable changed file and indexes it when a later refresh can read it', async () => { + it.skipIf(!builtinPatchingReachesImporters)('rejects an unreadable changed file and indexes it when a later refresh can read it', async () => { const source = path.join(dir, 'src/handlers.ts'); write('src/handlers.ts', 'export function readableAfterRetry() {}\n'); const mutableFs = require('fs') as typeof fs; diff --git a/__tests__/worker-teardown.test.ts b/__tests__/worker-teardown.test.ts index 91ef593cc3..c43d9622a0 100644 --- a/__tests__/worker-teardown.test.ts +++ b/__tests__/worker-teardown.test.ts @@ -57,7 +57,7 @@ describe('terminating a worker once it has started', () => { }); describe('collecting garbage before a worker exits', () => { - it('runs a full collection in a real worker, and leaves no global gc behind', async () => { + it('runs a full collection in a real worker (V8), and leaves no global gc behind', async () => { const teardown = path.resolve(__dirname, '../dist/worker-teardown.js'); const run = (code: string) => new Promise((resolve, reject) => { const w = new Worker(code, { eval: true }); @@ -73,8 +73,15 @@ describe('collecting garbage before a worker exits', () => { const after = process.memoryUsage().heapUsed; require('worker_threads').parentPort.postMessage({ collected, freed: before - after, gc: typeof globalThis.gc }); `) as { collected: boolean; freed: number; gc: string }; - expect(ran.collected).toBe(true); - expect(ran.freed).toBeGreaterThan(0); + if (process.versions.bun) { + // JavaScriptCore has no V8 `--expose-gc` flag to reach at runtime, so the + // collector is unavailable and the worker exits without one. The crash it + // guards against is V8's concurrent marker on Windows. + expect(ran.collected).toBe(false); + } else { + expect(ran.collected).toBe(true); + expect(ran.freed).toBeGreaterThan(0); + } expect(ran.gc).toBe('undefined'); // A worker created afterwards doesn't get a global gc either. expect(await run(`require('worker_threads').parentPort.postMessage(typeof globalThis.gc)`)).toBe('undefined'); diff --git a/__tests__/writer-lock.test.ts b/__tests__/writer-lock.test.ts index 1e2c0635cd..c119ac1d40 100644 --- a/__tests__/writer-lock.test.ts +++ b/__tests__/writer-lock.test.ts @@ -18,6 +18,7 @@ import { tryAcquireWriterLock, writerLockHeldMessage, } from '../src/mcp/writer-lock'; +import { builtinPatchingReachesImporters } from './helpers/runtime-capabilities'; describe('writer lock (#1740)', () => { let dir: string; @@ -73,7 +74,7 @@ describe('writer lock (#1740)', () => { expect(fs.readFileSync(file, 'utf8')).toBe('uncertain'); }); - it.each(['uncertain', 'invalid-pid', 'same-pid-successor'])( + it.skipIf(!builtinPatchingReachesImporters).each(['uncertain', 'invalid-pid', 'same-pid-successor'])( 'preserves a %s record arriving during stale-writer cleanup', (successor) => { const root = makeProject(); const file = getWriterPidPath(root); diff --git a/src/directory.ts b/src/directory.ts index b5ac8b7457..4a7ef3818c 100644 --- a/src/directory.ts +++ b/src/directory.ts @@ -23,7 +23,7 @@ let warnedBadDirName = false; /** * A directory's entries in name order. Node's `readdirSync` returns them sorted - * (libuv sorts); Bun returns the filesystem's own order. Indexing, resolution + * (libuv sorts); Bun returns the filesystem's own order (oven-sh/bun#44947). Indexing, resolution * and equal-score tie-breaks follow this order, so every walk that feeds them * reads through here and gets the same order on both runtimes. */ From 0563751ef7987ff21d60153a30b2498cbe965716 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Sat, 10 Oct 2026 17:14:38 -0600 Subject: [PATCH 2/3] test: compare raw database bytes outside Bun in the PID-reuse test A read-only connection cannot recover a killed writer's WAL on Windows, so the content comparison fails there with a disk I/O error. Node and Windows keep the byte comparison; only Bun compares indexed content. --- __tests__/mcp-daemon.test.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/__tests__/mcp-daemon.test.ts b/__tests__/mcp-daemon.test.ts index c390831e44..f873e8e329 100644 --- a/__tests__/mcp-daemon.test.ts +++ b/__tests__/mcp-daemon.test.ts @@ -57,14 +57,16 @@ interface SpawnedServer { } /** - * The indexed content of a project's database, read through a read-only - * connection. Comparing it instead of the raw file bytes asks "did anything - * change the index?" without failing when a checkpoint folds WAL frames left - * by a killed writer back into the main file (Bun does that at times; the - * rows are identical). + * The database state this test asserts did not change. Node (and Windows, where + * a read-only connection cannot recover a killed writer's WAL) compares the raw + * file bytes. Under Bun a checkpoint sometimes folds that WAL's frames into the + * main file without changing any row, so Bun compares the indexed content + * through a read-only connection. */ -function readIndexedContent(root: string): string { - const db = new DatabaseSync(path.join(root, '.codegraph', 'codegraph.db'), { readOnly: true }); +function snapshotDatabase(root: string): Buffer | string { + const dbPath = path.join(root, '.codegraph', 'codegraph.db'); + if (!process.versions.bun) return fs.readFileSync(dbPath); + const db = new DatabaseSync(dbPath, { readOnly: true }); try { const files = db.prepare('SELECT path, content_hash, node_count FROM files ORDER BY path').all(); const nodes = db.prepare('SELECT count(*) AS n FROM nodes').get(); @@ -658,7 +660,7 @@ describe('Shared MCP daemon (issue #411)', () => { // Make the index stale by changing the source, without opening a new // SQLite writer against the database of the daemon we just killed. - const before = readIndexedContent(realRoot); + const before = snapshotDatabase(realRoot); fs.writeFileSync(path.join(realRoot, 'app.ts'), 'export function changedSymbol() {}\n'); const second = spawnServer(tempDir, env); servers.push(second); @@ -696,7 +698,7 @@ describe('Shared MCP daemon (issue #411)', () => { expect(fs.readFileSync(daemonPath, 'utf8')).toBe(staleDaemonLock); second.child.stdin.end(); await waitFor(() => second.child.exitCode !== null, 5000); - expect(readIndexedContent(realRoot)).toBe(before); + expect(snapshotDatabase(realRoot)).toEqual(before); expect(fs.readFileSync(writerPath, 'utf8')).toBe(staleWriterLock); }, 50000); From 1ee7ce8b0bfb1dfc9e66a1841eaecb4dde9fdb4b Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Sat, 10 Oct 2026 17:29:02 -0600 Subject: [PATCH 3/3] test: snapshot node rows, not just their count, in the Bun PID-reuse check --- __tests__/mcp-daemon.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/mcp-daemon.test.ts b/__tests__/mcp-daemon.test.ts index f873e8e329..5818b3dd1c 100644 --- a/__tests__/mcp-daemon.test.ts +++ b/__tests__/mcp-daemon.test.ts @@ -69,7 +69,7 @@ function snapshotDatabase(root: string): Buffer | string { const db = new DatabaseSync(dbPath, { readOnly: true }); try { const files = db.prepare('SELECT path, content_hash, node_count FROM files ORDER BY path').all(); - const nodes = db.prepare('SELECT count(*) AS n FROM nodes').get(); + const nodes = db.prepare('SELECT * FROM nodes ORDER BY id').all(); return JSON.stringify({ files, nodes }); } finally { db.close();