GitNexus/gitnexus/test/unit/group/bridge-db.test.ts

1315 lines
50 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { spawnSync } from 'node:child_process';
import fsp from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { cleanupTempDir } from '../../helpers/test-db.js';
import { tsxLoaderUrl } from '../../helpers/cli-entry.js';
import {
openBridgeDb,
ensureBridgeSchema,
queryBridge,
closeBridgeDb,
contractNodeId,
writeBridge,
writeBridgeUnlocked,
bridgeMetaMatchesFile,
openBridgeDbReadOnly,
readBridgeMeta,
bridgeExists,
createContractLookupIndex,
indexContract,
findContractNode,
type WriteBridgeInput,
} from '../../../src/core/group/bridge-db.js';
import { getGroupSyncLockDir, withGroupSyncLock } from '../../../src/core/group/group-lock.js';
import { BRIDGE_SCHEMA_VERSION } from '../../../src/core/group/bridge-schema.js';
import { retryRename } from '../../../src/storage/fs-atomic.js';
import type { BridgeHandle, CrossLink } from '../../../src/core/group/types.js';
import { makeContract } from './fixtures.js';
/**
* In-process close-then-reopen of `bridge.lbug` (`writeBridge →
* openBridgeDbReadOnly`, and the read path's open→query→close→reopen) — exactly
* what a long-lived MCP server does on repeated `@group` impact/trace calls.
*
* On Linux/macOS this is now a supported, exercised pattern thanks to the
* `closeBridgeDb` fix that skips CHECKPOINT on read-only handles (a CHECKPOINT
* on a read-only connection left a lock artifact that failed the next open).
*
* On WINDOWS the direct openBridgeDbReadOnly reopen still fails (see NOTE in
* closeBridgeDb). The read-only bridge-handle cache (getCachedBridgeReadOnly)
* solves this for production by keeping one handle alive across calls instead
* of reopening — see the `bridge handle cache` describe block. These tests
* exercise the DIRECT reopen path (bypassed by the cache) and stay skipped on
* Windows.
*/
const itLbugReopen = process.platform === 'win32' ? it.skip : it;
describe('bridge-db core', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-test-'));
});
afterEach(async () => {
await cleanupTempDir(tmpDir);
});
it('test_openBridgeDb_returns_handle_and_closes', async () => {
const dbPath = path.join(tmpDir, 'test.lbug');
const handle = await openBridgeDb(dbPath);
expect(handle).toBeDefined();
expect(handle._db).toBeDefined();
expect(handle._conn).toBeDefined();
expect(handle.groupDir).toBe(tmpDir);
// Close should not throw
await closeBridgeDb(handle);
});
it('test_ensureBridgeSchema_creates_tables_idempotent', async () => {
const dbPath = path.join(tmpDir, 'test.lbug');
const handle = await openBridgeDb(dbPath);
await ensureBridgeSchema(handle);
// Run again — should not throw
await ensureBridgeSchema(handle);
const rows = await queryBridge<{ cnt: number }>(
handle,
'MATCH (c:Contract) RETURN count(c) AS cnt',
);
expect(rows[0].cnt).toBe(0);
await closeBridgeDb(handle);
});
it('test_queryBridge_returns_inserted_data', async () => {
const dbPath = path.join(tmpDir, 'test.lbug');
const handle = await openBridgeDb(dbPath);
await ensureBridgeSchema(handle);
await queryBridge(
handle,
`CREATE (c:Contract {
id: 'abc123', contractId: 'http::GET::/api', type: 'http', role: 'provider',
repo: 'backend', confidence: 0.9
})`,
);
const rows = await queryBridge<{ repo: string; confidence: number }>(
handle,
'MATCH (c:Contract) RETURN c.repo AS repo, c.confidence AS confidence',
);
expect(rows).toHaveLength(1);
expect(rows[0].repo).toBe('backend');
expect(rows[0].confidence).toBe(0.9);
await closeBridgeDb(handle);
});
it('test_queryBridge_parameterized', async () => {
const dbPath = path.join(tmpDir, 'test.lbug');
const handle = await openBridgeDb(dbPath);
await ensureBridgeSchema(handle);
await queryBridge(
handle,
`CREATE (c:Contract {
id: 'p1', contractId: 'http::GET::/api', type: 'http', role: 'provider',
repo: 'backend', confidence: 0.9
})`,
);
const rows = await queryBridge<{ repo: string }>(
handle,
'MATCH (c:Contract) WHERE c.repo = $r RETURN c.repo AS repo',
{ r: 'backend' },
);
expect(rows).toHaveLength(1);
expect(rows[0].repo).toBe('backend');
await closeBridgeDb(handle);
});
it('test_contractNodeId_full_sha256', () => {
const id = contractNodeId('backend', 'http::GET::/api', 'provider', 'src/routes.ts');
expect(id).toHaveLength(64); // full SHA-256 hex
// Same inputs → same hash
const id2 = contractNodeId('backend', 'http::GET::/api', 'provider', 'src/routes.ts');
expect(id).toBe(id2);
// Different filePath → different hash
const id3 = contractNodeId('backend', 'http::GET::/api', 'provider', 'src/other.ts');
expect(id).not.toBe(id3);
});
});
describe('writeBridge + read', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-write-'));
});
afterEach(async () => {
await cleanupTempDir(tmpDir);
});
it('test_writeBridge_creates_bridge_lbug_file', async () => {
await writeBridge(tmpDir, {
contracts: [makeContract()],
crossLinks: [],
repoSnapshots: {
backend: { indexedAt: '2026-01-01', lastCommit: 'abc' },
},
missingRepos: ['missing-repo'],
});
const exists = await bridgeExists(tmpDir);
expect(exists).toBe(true);
});
it("replaces the previous sync's metadata on a successful rebuild", async () => {
// meta.json describes the bridge's completeness, and since #3011 that is
// load-bearing: runGroupImpact folds `unreadableRepos ∪ missingRepos` into
// its truncation fields, so a value from an earlier sync is a wrong answer
// about this one.
//
// Scope, stated because the obvious stronger reading is wrong: this covers
// the SUCCESSFUL path only. It cannot pin the removal-before-swap ordering,
// because writeBridge overwrites meta.json at the end either way — the
// assertions below hold with the removal in either position. The ordering
// is pinned in `bridge-meta-swap-window.test.ts`, which fails the swap
// itself and checks the previous sync's metadata cannot survive it.
await writeBridge(tmpDir, {
contracts: [makeContract()],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
});
const before = await readBridgeMeta(tmpDir);
await writeBridge(tmpDir, {
contracts: [makeContract()],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
unreadableRepos: ['svc/users'],
});
const after = await readBridgeMeta(tmpDir);
expect(before.unreadableRepos).toBeUndefined();
expect(after.unreadableRepos).toEqual(['svc/users']);
});
it('reports version 0 for a bridge whose meta.json is gone', async () => {
// `version: 0` is the "no provenance" signal `runGroupImpact` fails closed
// on, so it is worth asserting directly rather than only through the
// callers that consume it. No fault is injected into writeBridge here —
// the file is removed afterwards — so this pins readBridgeMeta's contract,
// not the write ordering (see `bridge-meta-swap-window.test.ts` for that).
await writeBridge(tmpDir, {
contracts: [makeContract()],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
unreadableRepos: ['svc/users'],
});
await fsp.rm(path.join(tmpDir, 'meta.json'), { force: true });
const meta = await readBridgeMeta(tmpDir);
expect(meta.version).toBe(0);
expect(meta.unreadableRepos).toBeUndefined();
});
it('persists an explicitly empty unreadableRepos measurement', async () => {
// Same distinction as the registry: `[]` means the sync accounted for every
// repo, and dropping it collapses that into "never recorded".
await writeBridge(tmpDir, {
contracts: [makeContract()],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
unreadableRepos: [],
});
const meta = await readBridgeMeta(tmpDir);
expect(meta.unreadableRepos).toEqual([]);
});
it('test_writeBridge_returns_report_with_insert_counts', async () => {
const report = await writeBridge(tmpDir, {
contracts: [makeContract(), makeContract({ repo: 'frontend', role: 'consumer' })],
crossLinks: [],
repoSnapshots: {
backend: { indexedAt: '2026-01-01', lastCommit: 'abc' },
},
missingRepos: [],
});
expect(report.contractsInserted).toBe(2);
expect(report.contractsFailed).toBe(0);
expect(report.snapshotsInserted).toBe(1);
expect(report.snapshotsFailed).toBe(0);
expect(report.linksInserted).toBe(0);
expect(report.linksFailed).toBe(0);
expect(report.linksDroppedMissingNode).toBe(0);
expect(report.sampleErrors).toHaveLength(0);
});
it('test_writeBridge_counts_dropped_links_with_missing_nodes', async () => {
// Provider + cross-link that references a non-existent consumer node →
// findContractNode returns null for `from`, link gets dropped.
const provider = makeContract({ role: 'provider' });
const report = await writeBridge(tmpDir, {
contracts: [provider],
crossLinks: [
{
from: {
repo: 'ghost',
symbolUid: '',
symbolRef: { filePath: 'nowhere.ts', name: 'ghostFn' },
},
to: {
repo: provider.repo,
symbolUid: provider.symbolUid,
symbolRef: provider.symbolRef,
},
type: 'http',
contractId: provider.contractId,
matchType: 'exact',
confidence: 1.0,
},
],
repoSnapshots: {},
missingRepos: [],
});
expect(report.linksInserted).toBe(0);
expect(report.linksDroppedMissingNode).toBe(1);
expect(report.linksFailed).toBe(0);
expect(report.contractsInserted).toBe(1);
});
itLbugReopen('test_writeBridge_contracts_queryable', async () => {
await writeBridge(tmpDir, {
contracts: [makeContract(), makeContract({ repo: 'frontend', role: 'consumer' })],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
});
const handle = await openBridgeDbReadOnly(tmpDir);
expect(handle).not.toBeNull();
const rows = await queryBridge<{ repo: string }>(
handle!,
'MATCH (c:Contract) RETURN c.repo AS repo',
);
expect(rows).toHaveLength(2);
await closeBridgeDb(handle!);
});
itLbugReopen('test_openBridgeDbReadOnly_can_reopen_in_same_process', async () => {
// Regression: closeBridgeDb used to issue CHECKPOINT on read-only handles
// too, which left a WAL/shadow lock artifact that made the next read-only
// open of the same file fail in-process — breaking repeated @group
// impact/trace calls in a long-lived MCP server. closeBridgeDb now skips
// the checkpoint for read-only handles, so open→query→close→open works.
await writeBridge(tmpDir, {
contracts: [makeContract()],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
});
const first = await openBridgeDbReadOnly(tmpDir);
expect(first).not.toBeNull();
const r1 = await queryBridge<{ n: number }>(first!, 'MATCH (c:Contract) RETURN count(c) AS n');
expect(r1[0].n).toBe(1);
await closeBridgeDb(first!);
// Second open in the SAME process must succeed (previously returned null).
const second = await openBridgeDbReadOnly(tmpDir);
expect(second).not.toBeNull();
const r2 = await queryBridge<{ n: number }>(second!, 'MATCH (c:Contract) RETURN count(c) AS n');
expect(r2[0].n).toBe(1);
await closeBridgeDb(second!);
// And a third, to confirm it is not a one-shot.
const third = await openBridgeDbReadOnly(tmpDir);
expect(third).not.toBeNull();
await closeBridgeDb(third!);
});
it('test_writeBridge_meta_json_persists_missingRepos', async () => {
await writeBridge(tmpDir, {
contracts: [],
crossLinks: [],
repoSnapshots: {},
missingRepos: ['repo-a', 'repo-b'],
});
const meta = await readBridgeMeta(tmpDir);
expect(meta.missingRepos).toEqual(['repo-a', 'repo-b']);
expect(meta.version).toBeGreaterThan(0);
expect(meta.generatedAt).toBeTruthy();
});
itLbugReopen('test_writeBridge_repoSnapshots_queryable', async () => {
await writeBridge(tmpDir, {
contracts: [],
crossLinks: [],
repoSnapshots: {
'hr/backend': { indexedAt: '2026-01-01', lastCommit: 'abc' },
},
missingRepos: [],
});
const handle = await openBridgeDbReadOnly(tmpDir);
const rows = await queryBridge<{ id: string; indexedAt: string }>(
handle!,
'MATCH (s:RepoSnapshot) RETURN s.id AS id, s.indexedAt AS indexedAt',
);
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe('hr/backend');
expect(rows[0].indexedAt).toBe('2026-01-01');
await closeBridgeDb(handle!);
});
itLbugReopen('test_writeBridge_crossLinks_queryable', async () => {
const provider = makeContract({ repo: 'backend', role: 'provider' });
const consumer = makeContract({
repo: 'frontend',
role: 'consumer',
symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' },
symbolName: 'fetchUsers',
});
const link: CrossLink = {
from: {
repo: 'frontend',
symbolUid: '',
symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' },
},
to: {
repo: 'backend',
symbolUid: 'uid-1',
symbolRef: { filePath: 'src/routes.ts', name: 'getUsers' },
},
type: 'http',
contractId: 'http::GET::/api/users',
matchType: 'exact',
confidence: 1.0,
};
await writeBridge(tmpDir, {
contracts: [provider, consumer],
crossLinks: [link],
repoSnapshots: {},
missingRepos: [],
});
const handle = await openBridgeDbReadOnly(tmpDir);
const rows = await queryBridge<{
fromRepo: string;
toRepo: string;
matchType: string;
}>(
handle!,
'MATCH (a:Contract)-[l:ContractLink]->(b:Contract) RETURN l.fromRepo AS fromRepo, l.toRepo AS toRepo, l.matchType AS matchType',
);
expect(rows).toHaveLength(1);
expect(rows[0].fromRepo).toBe('frontend');
expect(rows[0].toRepo).toBe('backend');
expect(rows[0].matchType).toBe('exact');
await closeBridgeDb(handle!);
});
itLbugReopen('test_writeBridge_duplicate_contracts_and_links_are_deduped', async () => {
const provider = makeContract({
repo: 'backend',
role: 'provider',
symbolUid: '',
symbolName: 'auth.AuthService/Login',
symbolRef: { filePath: 'src/auth.proto', name: 'Login' },
contractId: 'grpc::auth.AuthService/Login',
type: 'grpc',
meta: { source: 'manifest' },
});
const concreteProvider = makeContract({
...provider,
symbolUid: 'uid-auth-login',
symbolName: 'Login',
confidence: 0.85,
meta: { source: 'analyze' },
});
const consumer = makeContract({
repo: 'frontend',
role: 'consumer',
symbolUid: '',
symbolName: 'auth.AuthService/Login',
symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' },
contractId: 'grpc::auth.AuthService/Login',
type: 'grpc',
meta: { source: 'manifest' },
});
const link: CrossLink = {
from: {
repo: 'frontend',
symbolUid: '',
symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' },
},
to: {
repo: 'backend',
symbolUid: '',
symbolRef: { filePath: 'src/auth.proto', name: 'Login' },
},
type: 'grpc',
contractId: 'grpc::auth.AuthService/Login',
matchType: 'manifest',
confidence: 1,
};
await writeBridge(tmpDir, {
contracts: [provider, concreteProvider, consumer],
crossLinks: [link, { ...link }],
repoSnapshots: {},
missingRepos: [],
});
const handle = await openBridgeDbReadOnly(tmpDir);
const contracts = await queryBridge<{
repo: string;
symbolUid: string;
symbolName: string;
}>(
handle!,
'MATCH (c:Contract) RETURN c.repo AS repo, c.symbolUid AS symbolUid, c.symbolName AS symbolName ORDER BY c.repo',
);
const links = await queryBridge<{ fromRepo: string; toRepo: string }>(
handle!,
'MATCH (a:Contract)-[l:ContractLink]->(b:Contract) RETURN l.fromRepo AS fromRepo, l.toRepo AS toRepo',
);
expect(contracts).toHaveLength(2);
expect(contracts[0]).toEqual({
repo: 'backend',
symbolUid: 'uid-auth-login',
symbolName: 'Login',
});
expect(links).toHaveLength(1);
await closeBridgeDb(handle!);
});
it('test_openBridgeDbReadOnly_returns_null_for_missing', async () => {
const handle = await openBridgeDbReadOnly(path.join(tmpDir, 'nonexistent'));
expect(handle).toBeNull();
});
it('test_bridgeExists_false_for_missing', async () => {
expect(await bridgeExists(path.join(tmpDir, 'nonexistent'))).toBe(false);
});
itLbugReopen('test_writeBridge_overwrites_previous', async () => {
await writeBridge(tmpDir, {
contracts: [makeContract()],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
});
await writeBridge(tmpDir, {
contracts: [makeContract({ repo: 'new-repo' })],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
});
const handle = await openBridgeDbReadOnly(tmpDir);
const rows = await queryBridge<{ repo: string }>(
handle!,
'MATCH (c:Contract) RETURN c.repo AS repo',
);
expect(rows).toHaveLength(1);
expect(rows[0].repo).toBe('new-repo');
await closeBridgeDb(handle!);
});
it('test_readBridgeMeta_returns_defaults_for_missing', async () => {
const meta = await readBridgeMeta(path.join(tmpDir, 'nonexistent'));
expect(meta.version).toBe(0);
expect(meta.generatedAt).toBe('');
expect(meta.missingRepos).toEqual([]);
});
});
/* ------------------------------------------------------------------ */
/* The bridge swap runs inside the caller's critical section (R9) */
/* ------------------------------------------------------------------ */
/**
* R9, writer-writer half. The swap and the metadata write are two operations:
* `bridge.lbug` is renamed into place first, and only then is `meta.json`
* written with the size and mtime of the file it describes. Two writers that
* overlap can therefore leave one writer's metadata beside the other's
* database. What prevents it is the group sync lock, held across the whole
* swap.
*
* That is why the swap comes in two halves. `writeBridgeUnlocked` assumes the
* lock is already held and is what `syncGroup` calls from inside its
* `withGroupSyncLock` region; `writeBridge` is the thin acquiring wrapper for
* callers that are not already in that region — every caller in this file, and
* every other direct caller in the suite. Routing the held-lock caller through
* the wrapper instead would be a SECOND acquisition of a non-reentrant
* primitive, which does not fail fast: it waits out the ten-minute ceiling
* against a lock its own call stack holds. The first case below is the
* regression gate for exactly that, and it goes red by timeout.
*
* SCOPE, so the block is not read as more than it is: this is writer-writer
* exclusion only. The reader-side promotion of a leftover backup file runs on
* ordinary reads, outside anyone's critical section, and `bridgeMetaMatchesFile`
* remains the reader's defense there.
*
* Nothing here opens `bridge.lbug`. The in-process write-then-read reopen is
* the documented LadybugDB Windows limitation this file skips elsewhere
* (`itLbugReopen`), so every assertion below is made on file state and on
* `bridgeMetaMatchesFile`, which reads `meta.json` and the database's `stat`
* and never opens it. No case in this block is platform-skipped, and the
* surrounding `writeBridge + read` describe is the unchanged control for the
* single-direct-call path.
*/
describe("writeBridge — the swap runs inside the caller's critical section (R9)", () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-lock-'));
});
afterEach(async () => {
await cleanupTempDir(tmpDir);
});
/** One contract, plus a `missingRepos` marker naming the writer that built it. */
const payload = (writer: string): WriteBridgeInput => ({
contracts: [makeContract({ repo: writer })],
crossLinks: [],
repoSnapshots: {},
missingRepos: [writer],
});
/**
* How long a contended writer is given to finish while the lock is held
* elsewhere. An uncontended write of this payload takes ~50-110ms in this
* suite, so the window is more than fifteen times the work — "still not
* finished" is a statement about the lock rather than about how fast the host
* is, and a wrapper that does not acquire finishes inside it on any host.
*/
const CONTENDED_WINDOW_MS = 2000;
/**
* A plain existence check. Deliberately NOT `bridgeExists`, which is a READER
* and promotes a leftover `bridge.lbug.bak` back into place on its way to an
* answer — the reader-side path this block makes no claim about, and one that
* would repair the crashed state the last case is trying to hand to the next
* writer.
*/
const onDisk = (name: string): Promise<boolean> =>
fsp.access(path.join(tmpDir, name)).then(
() => true,
() => false,
);
it('a caller that already holds the group lock completes the swap without acquiring a second one', async () => {
// The production shape: `syncGroup` holds the lock across its whole persist
// section and calls the LOCK-FREE half from inside it. `acquireIndexLock` is
// not reentrant, so a swap that acquired for itself would not fail fast — it
// would wait out GROUP_SYNC_LOCK_TIMEOUT_MS (ten minutes) against a lock this
// very call stack is holding. The short per-case timeout is the assertion:
// this case goes red by TIMEOUT the moment the inner half starts acquiring.
const report = await withGroupSyncLock(tmpDir, () =>
writeBridgeUnlocked(tmpDir, payload('held-lock-caller')),
);
expect(report.contractsInserted).toBe(1);
expect(await bridgeExists(tmpDir)).toBe(true);
const meta = await readBridgeMeta(tmpDir);
expect(meta.missingRepos).toEqual(['held-lock-caller']);
expect(await bridgeMetaMatchesFile(tmpDir, meta)).toBe(true);
}, 15_000);
it('a direct write cannot enter the swap while another holder has the group lock', async () => {
// The exclusion itself, observed as ordering rather than as a race: a holder
// takes the group lock, a direct `writeBridge` starts underneath it, and the
// write may not complete until the holder lets go. Nothing is mocked — the
// holder takes the same real lock the wrapper does.
const order: string[] = [];
let markHeld!: () => void;
const lockIsHeld = new Promise<void>((resolve) => {
markHeld = resolve;
});
let releaseHolder!: () => void;
const holderMayRelease = new Promise<void>((resolve) => {
releaseHolder = resolve;
});
const holder = withGroupSyncLock(tmpDir, async () => {
markHeld();
await holderMayRelease;
order.push('holder-released');
});
await lockIsHeld;
let settled = false;
const contender = writeBridge(tmpDir, payload('contender')).then(() => {
order.push('write-finished');
settled = true;
});
await new Promise((resolve) => setTimeout(resolve, CONTENDED_WINDOW_MS));
// The write is still outside the swap. Without the wrapper's acquisition it
// has long since finished, and the ordering assertion below inverts.
expect(settled).toBe(false);
// And it has not written anything either: the swap is what produces both files.
expect(await onDisk('bridge.lbug')).toBe(false);
expect(await onDisk('meta.json')).toBe(false);
releaseHolder();
await holder;
await contender;
expect(order).toEqual(['holder-released', 'write-finished']);
expect((await readBridgeMeta(tmpDir)).missingRepos).toEqual(['contender']);
}, 30_000);
it('after two contended direct writes the metadata on disk vouches for the database on disk', async () => {
// Two writers into one group at once. Serialized, the loser's swap completes
// in full before the winner's begins, so what is left is one writer's
// database under one writer's metadata — never a mixture, and never a stamp
// taken from the other writer's file.
const [first, second] = await Promise.all([
writeBridge(tmpDir, payload('writer-a')),
writeBridge(tmpDir, payload('writer-b')),
]);
expect(first.contractsInserted).toBe(1);
expect(second.contractsInserted).toBe(1);
const meta = await readBridgeMeta(tmpDir);
// Exactly one writer's measurement — not both, not neither.
expect(meta.missingRepos).toHaveLength(1);
expect(['writer-a', 'writer-b']).toContain(meta.missingRepos[0]);
// ...and the stamp it carries describes the database that is actually there.
expect(await bridgeMetaMatchesFile(tmpDir, meta)).toBe(true);
expect(meta.provenanceUnknown).toBeUndefined();
expect(meta.version).toBe(BRIDGE_SCHEMA_VERSION);
// Nothing is left half-swapped: the backup was consumed and neither staging
// directory survives its writer.
const left = await fsp.readdir(tmpDir);
expect(left.filter((f) => f.startsWith('bridge.lbug.bak'))).toEqual([]);
expect(left.filter((f) => f.startsWith('bridge-tmp-'))).toEqual([]);
}, 60_000);
it('the wrapper releases the group lock, so the next writer is not blocked by the last one', async () => {
await writeBridge(tmpDir, payload('first'));
// Sequential, not concurrent. A wrapper that acquired and never released
// would not fail here — it would hang until the ten-minute ceiling, which is
// what the short per-case timeout turns into a red.
await expect(withGroupSyncLock(tmpDir, async () => 'free')).resolves.toBe('free');
const again = await writeBridge(tmpDir, payload('second'));
expect(again.contractsInserted).toBe(1);
const meta = await readBridgeMeta(tmpDir);
expect(meta.missingRepos).toEqual(['second']);
expect(await bridgeMetaMatchesFile(tmpDir, meta)).toBe(true);
}, 30_000);
it('releases the native database before returning, while the writer process is still alive', async () => {
await writeBridge(tmpDir, payload('writer'));
const meta = await readBridgeMeta(tmpDir);
expect(await bridgeMetaMatchesFile(tmpDir, meta)).toBe(true);
// Block this process while a new writer opens the published bridge. This
// prevents parent-side GC from hiding QueryResults that still own the DB.
const reader = spawnSync(
process.execPath,
[
'--import',
tsxLoaderUrl(),
'--input-type=module',
'-e',
`
const { openBridgeDb, queryBridge, closeBridgeDb } = await import(process.argv[1]);
const handle = await openBridgeDb(process.argv[2]);
try {
const rows = await queryBridge(handle, 'MATCH (c:Contract) RETURN c.repo AS repo');
console.log(JSON.stringify(rows));
} finally {
await closeBridgeDb(handle);
}
`,
new URL('../../../src/core/group/bridge-db.ts', import.meta.url).href,
path.join(tmpDir, 'bridge.lbug'),
],
{ encoding: 'utf8', timeout: 15_000 },
);
expect(reader.error).toBeUndefined();
expect(reader.status, reader.stderr).toBe(0);
expect(JSON.parse(reader.stdout)).toEqual([{ repo: 'writer' }]);
}, 30_000);
it('a writer that died mid-swap leaves state the next write recovers from', async () => {
await writeBridge(tmpDir, payload('before-the-crash'));
// Reproduce what a process killed between the two renames leaves behind: the
// live database moved aside to `bridge.lbug.bak`, no `bridge.lbug` at all,
// the staging directory it never cleaned up, and its lock directory still on
// disk. That last one matters on the file backend, where the directory
// outlives the holder; on the socket backend the kernel drops the binding
// when the holder dies and there is nothing on disk to leave. Pre-creating it
// is harmless there and load-bearing here, which is why it is not skipped.
await fsp.rename(path.join(tmpDir, 'bridge.lbug'), path.join(tmpDir, 'bridge.lbug.bak'));
for (const suffix of ['.wal', '.shadow']) {
await fsp
.rename(
path.join(tmpDir, `bridge.lbug${suffix}`),
path.join(tmpDir, `bridge.lbug.bak${suffix}`),
)
.catch(() => {
/* sidecar absent — nothing to move */
});
}
const orphanStaging = path.join(tmpDir, 'bridge-tmp-deadwriter');
await fsp.mkdir(orphanStaging, { recursive: true });
await fsp.writeFile(path.join(orphanStaging, 'bridge.lbug'), 'half-written');
await fsp.mkdir(getGroupSyncLockDir(tmpDir), { recursive: true });
expect(await onDisk('bridge.lbug')).toBe(false);
expect(await onDisk('bridge.lbug.bak')).toBe(true);
const report = await writeBridge(tmpDir, payload('after-the-crash'));
expect(report.contractsInserted).toBe(1);
expect(await onDisk('bridge.lbug')).toBe(true);
const meta = await readBridgeMeta(tmpDir);
expect(meta.missingRepos).toEqual(['after-the-crash']);
expect(await bridgeMetaMatchesFile(tmpDir, meta)).toBe(true);
// The dead writer's backup is consumed by the recovery, not inherited by it.
expect(await onDisk('bridge.lbug.bak')).toBe(false);
}, 60_000);
});
/* ------------------------------------------------------------------ */
/* getCachedBridgeReadOnly cache tests */
/* ------------------------------------------------------------------ */
/**
* The RO bridge-handle cache avoids reopening bridge.lbug per @group
* tool call, which fails on Windows (the OS handle isn't fully released
* before the next open races in). These tests verify read→read reuse and
* mtime/size-based invalidation on macOS/Linux. Each begins with the
* beforeEach `writeBridge` (writable) followed by a read-only open, i.e. the
* in-process write→read reopen that is the unfixed LadybugDB Windows
* limitation — so every test here is `itCacheReopen` (win32-skipped). The
* cache layer (one handle held alive) is what these exercise, not the native
* close-then-reopen the itLbugReopen tests cover.
*/
describe('bridge handle cache', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-cache-test-'));
// Create a valid bridge.lbug to open
await writeBridge(tmpDir, {
contracts: [makeContract()],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
});
});
afterEach(async () => {
// Close any cached handles so cleanupTempDir doesn't hit EBUSY
const { closeAllCachedBridges } = await import('../../../src/core/group/bridge-db.js');
await closeAllCachedBridges();
await cleanupTempDir(tmpDir);
});
// The beforeEach calls writeBridge (writable) then the test body opens
// read-only via getCachedBridgeReadOnly. On Windows this in-process
// write→read reopen is the known LadybugDB limitation (same class as
// itLbugReopen) — the OS handle isn't fully released after the writer
// closes. The cache exercises read→read reuse, not write→read, so the
// skip only affects the test setup, not the cache logic.
const itCacheReopen = process.platform === 'win32' ? it.skip : it;
itCacheReopen('same groupDir returns the same handle instance', async () => {
const { getCachedBridgeReadOnly } = await import('../../../src/core/group/bridge-db.js');
const first = await getCachedBridgeReadOnly(tmpDir);
expect(first).not.toBeNull();
const second = await getCachedBridgeReadOnly(tmpDir);
expect(second).not.toBeNull();
// Must be the SAME object — not a new open
expect(second).toBe(first);
});
itCacheReopen('writeBridge self-invalidates the cache (no manual invalidate)', async () => {
const { getCachedBridgeReadOnly, queryBridge, closeBridgeDb } =
await import('../../../src/core/group/bridge-db.js');
const first = await getCachedBridgeReadOnly(tmpDir);
expect(first).not.toBeNull();
// Rewrite the bridge WITHOUT calling invalidateBridgeCache here — writeBridge
// must self-invalidate (impl: invalidateBridgeCache before its atomic rename).
// A manual invalidate would mask that, leaving the load-bearing invariant
// untested.
await writeBridge(tmpDir, {
contracts: [makeContract({ repo: 'updated-repo' })],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
});
const second = await getCachedBridgeReadOnly(tmpDir);
expect(second).not.toBeNull();
// Different handle — writeBridge's own invalidate dropped the old entry.
expect(second).not.toBe(first);
// New handle sees the updated data.
const rows = await queryBridge<{ repo: string }>(
second!,
'MATCH (c:Contract) RETURN c.repo AS repo',
);
expect(rows).toMatchObject([{ repo: 'updated-repo' }]);
// Release leases as real consumers do (finally{closeBridgeDb}). `first` was
// evicted by writeBridge (close deferred behind this lease) — releasing it
// fires the deferred native close.
await closeBridgeDb(first!);
await closeBridgeDb(second!);
});
itCacheReopen('cache reopens when an external writer bumps mtime', async () => {
// Exercises the stat-based mtime/size invalidation branch directly, WITHOUT
// going through writeBridge's own invalidate. bridge.lbug is a native binary,
// so we bump mtime with fsp.utimes (rewriting bytes would corrupt it and the
// reopen would return null). Simulates another process having written the
// bridge out-of-band.
const { getCachedBridgeReadOnly, queryBridge, closeBridgeDb } =
await import('../../../src/core/group/bridge-db.js');
const dbPath = path.join(tmpDir, 'bridge.lbug');
const first = await getCachedBridgeReadOnly(tmpDir);
expect(first).not.toBeNull();
const future = new Date(Date.now() + 5000);
await fsp.utimes(dbPath, future, future);
const second = await getCachedBridgeReadOnly(tmpDir);
expect(second).not.toBeNull();
// mtime moved → the fast path missed → a fresh handle was opened.
expect(second).not.toBe(first);
const rows = await queryBridge<{ repo: string }>(
second!,
'MATCH (c:Contract) RETURN c.repo AS repo',
);
expect(rows).toMatchObject([{ repo: 'backend' }]);
await closeBridgeDb(first!);
await closeBridgeDb(second!);
});
itCacheReopen('concurrent calls return the same handle instance', async () => {
const { getCachedBridgeReadOnly } = await import('../../../src/core/group/bridge-db.js');
// Fire N concurrent cache-miss calls — the TOCTOU guard should make
// only ONE actual openBridgeDbReadOnly call; all the rest await it.
const N = 10;
const results = await Promise.all(
Array.from({ length: N }, () => getCachedBridgeReadOnly(tmpDir)),
);
// All returned the same handle instance (proves no double-open)
const first = results[0]!;
for (const h of results) {
expect(h).toBe(first);
}
// Verify the handle works — query returns expected data
const { queryBridge } = await import('../../../src/core/group/bridge-db.js');
const rows = await queryBridge<{ repo: string }>(
first,
'MATCH (c:Contract) RETURN c.repo AS repo',
);
expect(rows).toHaveLength(1);
expect(rows[0].repo).toBe('backend');
});
});
/* ------------------------------------------------------------------ */
/* Concurrency: per-handle query serialization + refcount lease */
/* ------------------------------------------------------------------ */
/**
* The cached RO handle is shared across concurrent @group callers in a
* long-lived MCP serve process. Two correctness properties must hold:
* 1. No two queries run on one connection at once (LadybugDB Connection is
* not concurrency-safe — conn-lock.ts). queryBridge serializes per handle.
* 2. The native handle is never closed while a reader holds a lease, and is
* closed exactly once on the last release (refcount).
*/
describe('bridge handle lock (withHandleLock)', () => {
it('serializes — never two operations overlap on one lock', async () => {
const { withHandleLock } = await import('../../../src/core/group/bridge-db.js');
const lock = { lockTail: Promise.resolve() };
let active = 0;
const observedMax: number[] = [];
const section = async () => {
active++;
observedMax.push(active);
await new Promise((r) => setTimeout(r, 1));
active--;
};
await Promise.all(Array.from({ length: 8 }, () => withHandleLock(lock, section)));
// If two sections ever overlapped, active would reach 2.
expect(Math.max(...observedMax)).toBe(1);
});
it('releases the lock when an operation throws (chain not wedged)', async () => {
const { withHandleLock } = await import('../../../src/core/group/bridge-db.js');
const lock = { lockTail: Promise.resolve() };
await expect(
withHandleLock(lock, async () => {
throw new Error('boom');
}),
).rejects.toThrow('boom');
// A subsequent op still runs — the failed op released its tail.
const result = await withHandleLock(lock, async () => 'ok');
expect(result).toBe('ok');
});
});
describe('bridge handle cache — refcount lease', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-refcount-test-'));
await writeBridge(tmpDir, {
contracts: [makeContract()],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
});
});
afterEach(async () => {
const { closeAllCachedBridges } = await import('../../../src/core/group/bridge-db.js');
await closeAllCachedBridges();
await cleanupTempDir(tmpDir);
});
// Each test below opens RO right after the beforeEach writeBridge (write→read
// reopen) — the unfixed Windows limitation — so all are win32-skipped.
const itCacheReopen = process.platform === 'win32' ? it.skip : it;
// Spy on the native close of a handle without `any` (strict-typing rule):
// _conn is typed `unknown`, so cast to the minimal structural shape we use.
const spyConnClose = (handle: BridgeHandle) =>
vi.spyOn(handle._conn as { close: () => Promise<unknown> }, 'close');
itCacheReopen('invalidate defers the native close until the last lease releases', async () => {
const { getCachedBridgeReadOnly, invalidateBridgeCache, closeBridgeDb } =
await import('../../../src/core/group/bridge-db.js');
// Two leases on the same cached handle (refs === 2).
const a = await getCachedBridgeReadOnly(tmpDir);
const b = await getCachedBridgeReadOnly(tmpDir);
expect(a).not.toBeNull();
expect(b).toBe(a);
const closeSpy = spyConnClose(a!);
// group_sync-style invalidate while readers hold leases → close deferred.
await invalidateBridgeCache(tmpDir);
expect(closeSpy).not.toHaveBeenCalled();
// First release: refs 2 → 1, still not closed.
await closeBridgeDb(a!);
expect(closeSpy).not.toHaveBeenCalled();
// Last release: refs 1 → 0, native close fires exactly once.
await closeBridgeDb(b!);
expect(closeSpy).toHaveBeenCalledTimes(1);
});
itCacheReopen('refs count every awaiter under inFlightOpens (not just one)', async () => {
const { getCachedBridgeReadOnly, invalidateBridgeCache, closeBridgeDb } =
await import('../../../src/core/group/bridge-db.js');
// N concurrent cache-miss calls coalesce to one open but each takes a lease.
const N = 5;
const handles = await Promise.all(
Array.from({ length: N }, () => getCachedBridgeReadOnly(tmpDir)),
);
const first = handles[0]!;
expect(handles).toMatchObject(Array.from({ length: N }, () => first));
const closeSpy = spyConnClose(first);
await invalidateBridgeCache(tmpDir);
// Release N-1 leases — if refs had been miscounted as 1, the close would
// have fired on the first release. It must not.
for (let i = 0; i < N - 1; i++) await closeBridgeDb(first);
expect(closeSpy).not.toHaveBeenCalled();
// The Nth release drops refs to 0 → close once.
await closeBridgeDb(first);
expect(closeSpy).toHaveBeenCalledTimes(1);
});
itCacheReopen('mtime-evict also defers close while a lease is held', async () => {
const { getCachedBridgeReadOnly, closeBridgeDb } =
await import('../../../src/core/group/bridge-db.js');
const dbPath = path.join(tmpDir, 'bridge.lbug');
const stale = await getCachedBridgeReadOnly(tmpDir);
expect(stale).not.toBeNull();
const closeSpy = spyConnClose(stale!);
// External writer bumps mtime; the next get evicts the stale entry. The
// lease on `stale` is still held, so its close must defer (this is the
// OTHER live close-under-lease path, alongside invalidate).
const future = new Date(Date.now() + 5000);
await fsp.utimes(dbPath, future, future);
const fresh = await getCachedBridgeReadOnly(tmpDir);
expect(fresh).not.toBe(stale);
expect(closeSpy).not.toHaveBeenCalled();
// Releasing the stale lease fires its deferred close exactly once.
await closeBridgeDb(stale!);
expect(closeSpy).toHaveBeenCalledTimes(1);
await closeBridgeDb(fresh!);
});
// Exercises the win32-only bounded-drain branch by mocking process.platform on
// a non-Windows runner (the real win32 path is proven by the cross-process
// integration test; this proves the branch LOGIC — that invalidate blocks
// until the reader releases on Windows rather than racing the rename).
// Skipped on real win32 (its in-process setup is the unsupported reopen).
itCacheReopen('on win32, invalidate waits for the in-flight reader to drain', async () => {
const { getCachedBridgeReadOnly, invalidateBridgeCache, closeBridgeDb } =
await import('../../../src/core/group/bridge-db.js');
const handle = await getCachedBridgeReadOnly(tmpDir);
expect(handle).not.toBeNull();
const realPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
try {
let invalidateResolved = false;
const invalidate = invalidateBridgeCache(tmpDir).then(() => {
invalidateResolved = true;
});
// With a lease held, the win32 drain must NOT resolve yet (POSIX would
// return immediately here — that's the platform difference under test).
await new Promise((r) => setTimeout(r, 20));
expect(invalidateResolved).toBe(false);
// Releasing the lease drains refs→0, closes the handle, and unblocks the
// waiting invalidate well within the bounded timeout.
await closeBridgeDb(handle!);
await invalidate;
expect(invalidateResolved).toBe(true);
} finally {
Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true });
}
});
});
describe('retryRename', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('retries on EBUSY and eventually succeeds', async () => {
// Spy on fs.promises.rename and make the first two attempts fail with
// EBUSY, then succeed on the third. Verifies that Windows-style
// transient rename failures don't immediately bubble up.
const attempts: Array<[string, string]> = [];
let calls = 0;
const spy = vi.spyOn(fsp, 'rename').mockImplementation(async (src, dst) => {
attempts.push([String(src), String(dst)]);
calls++;
if (calls < 3) {
const err = new Error('resource busy or locked') as NodeJS.ErrnoException;
err.code = 'EBUSY';
throw err;
}
// Third attempt: pretend the rename worked.
return undefined;
});
await retryRename('/src/a', '/dst/b', 3);
expect(spy).toHaveBeenCalledTimes(3);
expect(attempts.every(([s, d]) => s === '/src/a' && d === '/dst/b')).toBe(true);
});
it('rethrows non-retryable errors immediately', async () => {
// A non-retryable code (e.g. ENOENT) should NOT be swallowed into a
// retry loop — that would mask real bugs and waste time.
let calls = 0;
vi.spyOn(fsp, 'rename').mockImplementation(async () => {
calls++;
const err = new Error('no such file') as NodeJS.ErrnoException;
err.code = 'ENOENT';
throw err;
});
await expect(retryRename('/src/a', '/dst/b', 5)).rejects.toMatchObject({
code: 'ENOENT',
});
expect(calls).toBe(1);
});
it('gives up after the configured number of attempts', async () => {
let calls = 0;
vi.spyOn(fsp, 'rename').mockImplementation(async () => {
calls++;
const err = new Error('locked') as NodeJS.ErrnoException;
err.code = 'EPERM';
throw err;
});
await expect(retryRename('/src/a', '/dst/b', 3)).rejects.toMatchObject({
code: 'EPERM',
});
expect(calls).toBe(3);
});
it('retries on EACCES as well', async () => {
let calls = 0;
vi.spyOn(fsp, 'rename').mockImplementation(async () => {
calls++;
if (calls < 2) {
const err = new Error('permission denied') as NodeJS.ErrnoException;
err.code = 'EACCES';
throw err;
}
return undefined;
});
await retryRename('/src/a', '/dst/b', 3);
expect(calls).toBe(2);
});
});
describe('findContractNode', () => {
// Pure-function tests for the lookup index + three-tier resolver that
// were previously an inner closure of `writeBridge` and therefore
// untestable in isolation. Every test here builds its own index and
// never touches the DB.
it('returns null on empty index', () => {
const index = createContractLookupIndex();
expect(findContractNode(index, 'backend', 'provider', 'uid-1', 'src/a.ts', 'foo')).toBeNull();
});
it('tier 1: returns contract matched by symbolUid', () => {
const index = createContractLookupIndex();
const c = makeContract({
symbolUid: 'uid-42',
repo: 'backend',
role: 'provider',
});
indexContract(index, c, 'node-A');
expect(findContractNode(index, 'backend', 'provider', 'uid-42', 'anywhere.ts', 'anyName')).toBe(
'node-A',
);
});
it('tier 1 is repo-scoped: same uid in a different repo does not match', () => {
const index = createContractLookupIndex();
const c = makeContract({ symbolUid: 'uid-42', repo: 'backend' });
indexContract(index, c, 'node-A');
expect(
findContractNode(index, 'frontend', 'provider', 'uid-42', 'src/routes.ts', 'getUsers'),
).toBeNull();
});
it('tier 1 is role-scoped: provider uid match does not resolve consumer query', () => {
const index = createContractLookupIndex();
const c = makeContract({
symbolUid: 'uid-42',
role: 'provider',
repo: 'backend',
});
indexContract(index, c, 'node-A');
expect(
findContractNode(index, 'backend', 'consumer', 'uid-42', 'src/routes.ts', 'getUsers'),
).toBeNull();
});
it('tier 2: falls through to filePath + symbolName when symbolUid is empty', () => {
const index = createContractLookupIndex();
const c = makeContract({
symbolUid: '',
symbolRef: { filePath: 'src/ctrl.ts', name: 'handler' },
symbolName: 'handler',
});
indexContract(index, c, 'node-B');
expect(findContractNode(index, 'backend', 'provider', '', 'src/ctrl.ts', 'handler')).toBe(
'node-B',
);
});
it('tier 2: falls through when the given symbolUid does not match anything', () => {
const index = createContractLookupIndex();
const c = makeContract({
symbolUid: 'uid-real',
symbolRef: { filePath: 'src/ctrl.ts', name: 'handler' },
});
indexContract(index, c, 'node-B');
// Wrong uid; but filePath+name still resolves.
expect(
findContractNode(index, 'backend', 'provider', 'uid-wrong', 'src/ctrl.ts', 'handler'),
).toBe('node-B');
});
it('tier 3: resolves by filePath alone when exactly one contract lives there', () => {
const index = createContractLookupIndex();
const c = makeContract({
symbolUid: '',
symbolRef: { filePath: 'src/solo.ts', name: 'actualName' },
});
indexContract(index, c, 'node-C');
// filePath+name miss (name is wrong), but tier 3 picks the sole entry.
expect(findContractNode(index, 'backend', 'provider', '', 'src/solo.ts', 'wrongName')).toBe(
'node-C',
);
});
it('tier 3: does NOT resolve when multiple contracts live in the same file', () => {
const index = createContractLookupIndex();
const a = makeContract({
symbolUid: '',
symbolRef: { filePath: 'src/multi.ts', name: 'handlerA' },
});
const b = makeContract({
symbolUid: '',
symbolRef: { filePath: 'src/multi.ts', name: 'handlerB' },
contractId: 'http::GET::/api/b',
});
indexContract(index, a, 'node-MA');
indexContract(index, b, 'node-MB');
// Wrong symbolName → no tier 2 match. Two contracts in the same file
// → tier 3 must refuse to guess.
expect(
findContractNode(index, 'backend', 'provider', '', 'src/multi.ts', 'unknown'),
).toBeNull();
});
it('prefers tier 1 over tier 2 when both could resolve', () => {
const index = createContractLookupIndex();
const tier1Contract = makeContract({
symbolUid: 'uid-1',
symbolRef: { filePath: 'src/a.ts', name: 'first' },
});
const tier2Contract = makeContract({
symbolUid: '',
symbolRef: { filePath: 'src/a.ts', name: 'first' },
contractId: 'http::POST::/api/x',
});
indexContract(index, tier1Contract, 'tier1-id');
indexContract(index, tier2Contract, 'tier2-id');
expect(findContractNode(index, 'backend', 'provider', 'uid-1', 'src/a.ts', 'first')).toBe(
'tier1-id',
);
});
});
// The B2 cross-process rename-clash probe moved to
// test/integration/group/bridge-cache-reopen.test.ts, where a cross-process
// seed lets it run on win32 (the in-process write→read reopen no longer gates
// it). It empirically answers whether an open cached RO handle blocks an
// external atomic rename of bridge.lbug on Windows.