mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-11 22:53:04 +00:00
984 lines
35 KiB
TypeScript
984 lines
35 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import fsp from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import { cleanupTempDir } from '../../helpers/test-db.js';
|
|
import {
|
|
openBridgeDb,
|
|
ensureBridgeSchema,
|
|
queryBridge,
|
|
closeBridgeDb,
|
|
contractNodeId,
|
|
writeBridge,
|
|
openBridgeDbReadOnly,
|
|
readBridgeMeta,
|
|
bridgeExists,
|
|
createContractLookupIndex,
|
|
indexContract,
|
|
findContractNode,
|
|
} from '../../../src/core/group/bridge-db.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('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([]);
|
|
});
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 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.
|