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

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.