Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 157 additions & 3 deletions packages/api/src/__tests__/graph-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ describe('graph route numeric boundaries', () => {
totalEdges: 0,
windowOrder: 'degree-desc,id-asc',
degreeScope: 'global',
offset: 0,
limit: 100,
returned: 0,
hasMore: false,
nextOffset: null,
truncated: false,
});
mockedReferences.mockResolvedValue({ references: [], referencingFiles: [], truncated: false });
Expand Down Expand Up @@ -106,7 +111,23 @@ describe('graph route numeric boundaries', () => {
const response = await graphRoutes.request('/api/graph/full?limit=1000');

expect(response.status).toBe(200);
expect(mockedFullGraph).toHaveBeenCalledWith(1000, undefined);
expect(mockedFullGraph).toHaveBeenCalledWith(1000, undefined, 0);
});

it.each(['NaN', 'Infinity', '-1', '1.5'])(
'rejects full graph offset=%s before touching the graph',
async (offset) => {
const result = await errorFor(`/api/graph/full?offset=${offset}`);

expect(result).toEqual({ status: 400, error: 'offset must be a non-negative integer' });
expect(mockedFullGraph).not.toHaveBeenCalled();
},
);

it('forwards offset zero and positive offsets', async () => {
await graphRoutes.request('/api/graph/full?limit=25&offset=3000');

expect(mockedFullGraph).toHaveBeenCalledWith(25, undefined, 3000);
});

it('preserves full graph totals, ordering metadata, and truncation caveat', async () => {
Expand All @@ -117,6 +138,11 @@ describe('graph route numeric boundaries', () => {
totalEdges: 40,
windowOrder: 'degree-desc,id-asc',
degreeScope: 'global',
offset: 0,
limit: 10,
returned: 0,
hasMore: true,
nextOffset: 10,
truncated: true,
});

Expand All @@ -130,6 +156,11 @@ describe('graph route numeric boundaries', () => {
totalEdges: 40,
windowOrder: 'degree-desc,id-asc',
degreeScope: 'global',
offset: 0,
limit: 10,
returned: 0,
hasMore: true,
nextOffset: 10,
truncated: true,
});
});
Expand All @@ -153,6 +184,11 @@ describe('graph route numeric boundaries', () => {
totalEdges: 1,
windowOrder: 'degree-desc,id-asc',
degreeScope: 'global',
offset: 0,
limit: 10,
returned: 0,
hasMore: false,
nextOffset: null,
truncated: false,
});

Expand All @@ -173,7 +209,7 @@ describe('graph route numeric boundaries', () => {
const response = await graphRoutes.request('/api/graph/full?projectId=project-app');

expect(response.status).toBe(200);
expect(mockedFullGraph).toHaveBeenCalledWith(100, '/workspace/app');
expect(mockedFullGraph).toHaveBeenCalledWith(100, '/workspace/app', 0);
});

it('does not fall back to the global graph for an unknown projectId', async () => {
Expand Down Expand Up @@ -257,6 +293,11 @@ describe('GET /api/graph/full unavailable storage', () => {
totalEdges: 0,
windowOrder: 'degree-desc,id-asc',
degreeScope: 'global',
offset: 0,
limit: 100,
returned: 0,
hasMore: false,
nextOffset: null,
truncated: false,
storage: blockedSetupStatus.storage,
});
Expand All @@ -277,6 +318,11 @@ describe('GET /api/graph/files', () => {
totalNodes: 4,
totalEdges: 5,
windowOrder: 'degree-desc,id-asc' as const,
offset: 0,
limit: 50,
returned: 1,
hasMore: true,
nextOffset: 1,
truncated: true,
};

Expand Down Expand Up @@ -305,7 +351,23 @@ describe('GET /api/graph/files', () => {

expect(response.status).toBe(200);
expect(await response.json()).toEqual(fileGraphResult);
expect(getFileGraph).toHaveBeenCalledWith(50, '/x');
expect(getFileGraph).toHaveBeenCalledWith(50, '/x', 0);
});

it.each(['NaN', 'Infinity', '-1', '1.5'])(
'rejects offset=%s before touching the graph',
async (offset) => {
const result = await errorFor(`/api/graph/files?offset=${offset}`);

expect(result).toEqual({ status: 400, error: 'offset must be a non-negative integer' });
expect(mockedGetGraphClient).not.toHaveBeenCalled();
},
);

it('forwards a positive file graph offset', async () => {
await graphRoutes.request('/api/graph/files?limit=50&offset=3000');

expect(getFileGraph).toHaveBeenCalledWith(50, undefined, 3000);
});

it('does not return the global file graph for an unknown projectId', async () => {
Expand All @@ -321,6 +383,98 @@ describe('GET /api/graph/files', () => {
});
});

describe('POST /api/graph/induced-edges', () => {
const getInducedEdges = vi.fn();

beforeEach(() => {
vi.clearAllMocks();
mockedGetGraphClient.mockResolvedValue({
roQuery: vi.fn().mockResolvedValue({ data: [{ rootPath: '/x' }], metadata: [] }),
} as never);
getInducedEdges.mockResolvedValue([{
source: 'File:/x/a.ts',
target: 'File:/x/b.ts',
label: 'IMPORTS',
id: 'hidden',
data: { embedding: [0.1] },
}]);
mockedCreateQueries.mockReturnValue({ getInducedEdges } as never);
});

it.each([
undefined,
null,
{},
{ ids: 'File:/x/a.ts' },
{ ids: [1] },
])('rejects malformed body %j before touching the graph', async (body) => {
const response = await graphRoutes.request('/api/graph/induced-edges', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? '{' : JSON.stringify(body),
});

expect(response.status).toBe(400);
expect(await response.json()).toEqual({ error: 'body must be an object with an ids string array' });
expect(getInducedEdges).not.toHaveBeenCalled();
});

it('rejects more than 2000 ids before touching the graph', async () => {
const response = await graphRoutes.request('/api/graph/induced-edges', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: Array.from({ length: 2001 }, (_, index) => `node:${index}`) }),
});

expect(response.status).toBe(400);
expect(await response.json()).toEqual({ error: 'ids must contain at most 2000 items' });
expect(getInducedEdges).not.toHaveBeenCalled();
});

it('accepts exactly 2000 ids', async () => {
const ids = Array.from({ length: 2000 }, (_, index) => `node:${index}`);
const response = await graphRoutes.request('/api/graph/induced-edges', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});

expect(response.status).toBe(200);
expect(getInducedEdges).toHaveBeenCalledWith(ids, undefined);
});

it('returns only the public window edge shape and forwards project scope', async () => {
const ids = ['File:/x/a.ts', 'File:/x/b.ts', 'File:/x/missing.ts'];
const response = await graphRoutes.request('/api/graph/induced-edges?projectId=project-x', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});

expect(response.status).toBe(200);
expect(await response.json()).toEqual({
edges: [{ source: 'File:/x/a.ts', target: 'File:/x/b.ts', label: 'IMPORTS' }],
});
expect(getInducedEdges).toHaveBeenCalledWith(ids, '/x');
});

it('does not fall back to global scope for an unknown project', async () => {
mockedGetGraphClient.mockResolvedValueOnce({
roQuery: vi.fn().mockResolvedValue({ data: [], metadata: [] }),
} as never);

const response = await graphRoutes.request('/api/graph/induced-edges?projectId=missing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: [] }),
});

expect(response.status).toBe(404);
expect(await response.json()).toEqual({ error: 'Project not found' });
expect(getInducedEdges).not.toHaveBeenCalled();
});
});

describe('GET /api/graph/neighbors', () => {
const getNodeNeighbors = vi.fn();
const neighborResult = {
Expand Down
77 changes: 71 additions & 6 deletions packages/api/src/routes/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const graphRoutes = new Hono();

const FULL_GRAPH_LIMIT_MAX = 1000;
const FILE_GRAPH_LIMIT_MAX = 1000;
const INDUCED_EDGE_IDS_MAX = 2000;
const NEIGHBOR_LIMIT_MAX = 1000;
const FILE_RELATIONSHIP_LIMIT_MAX = 1000;
const REFERENCE_LIMIT_MAX = 1000;
Expand All @@ -31,6 +32,15 @@ function boundedPositiveInteger(
return { valid: true, value };
}

function nonNegativeInteger(raw: string | undefined, name: string): BoundedIntegerResult {
if (raw === undefined) return { valid: true };
const value = Number(raw);
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
return { valid: false, error: `${name} must be a non-negative integer` };
}
return { valid: true, value };
}

async function resolveProjectRootPath(projectId: string): Promise<string | null> {
const client = await getGraphClient();
const projectResult = await client.roQuery<{ rootPath: string | null }>(
Expand All @@ -51,24 +61,29 @@ function projectFullGraphResponse<T extends {
};
}

/** GET /api/graph/full?limit=N&projectId=X returns a degree-ordered window with scoped totals. */
/** GET /api/graph/full?limit=N&offset=N&projectId=X returns a degree-ordered page with scoped totals. */
graphRoutes.get('/api/graph/full', async (c) => {
let limit = 100;
let offset = 0;
try {
const parsedLimit = boundedPositiveInteger(c.req.query('limit'), 'limit', FULL_GRAPH_LIMIT_MAX);
if (!parsedLimit.valid) return c.json({ error: parsedLimit.error }, 400);
const limit = parsedLimit.value ?? 100;
const parsedOffset = nonNegativeInteger(c.req.query('offset'), 'offset');
if (!parsedOffset.valid) return c.json({ error: parsedOffset.error }, 400);
limit = parsedLimit.value ?? limit;
offset = parsedOffset.value ?? offset;
const projectId = c.req.query('projectId');

if (projectId) {
const rootPath = await resolveProjectRootPath(projectId);
if (!rootPath) return c.json({ error: 'Project not found' }, 404);
const data = await codeGraphService.getFullGraph(limit, rootPath);
const data = await codeGraphService.getFullGraph(limit, rootPath, offset);
return c.json(projectFullGraphResponse(data));
}

// No project filter — return all
const rootPath = c.req.query('rootPath') ?? undefined;
const data = await codeGraphService.getFullGraph(limit, rootPath);
const data = await codeGraphService.getFullGraph(limit, rootPath, offset);
return c.json(projectFullGraphResponse(data));
} catch (error) {
const setup = await readBlockedSetupStatus();
Expand All @@ -80,6 +95,11 @@ graphRoutes.get('/api/graph/full', async (c) => {
totalEdges: 0,
windowOrder: 'degree-desc,id-asc',
degreeScope: 'global',
offset,
limit,
returned: 0,
hasMore: false,
nextOffset: null,
truncated: false,
storage: setup.storage,
});
Expand All @@ -88,11 +108,15 @@ graphRoutes.get('/api/graph/full', async (c) => {
}
});

/** GET /api/graph/files?projectId=X&limit=N - bounded File-to-File IMPORTS graph. */
/** GET /api/graph/files?projectId=X&limit=N&offset=N - bounded File-to-File IMPORTS graph. */
graphRoutes.get('/api/graph/files', async (c) => {
try {
const parsedLimit = boundedPositiveInteger(c.req.query('limit'), 'limit', FILE_GRAPH_LIMIT_MAX);
if (!parsedLimit.valid) return c.json({ error: parsedLimit.error }, 400);
const parsedOffset = nonNegativeInteger(c.req.query('offset'), 'offset');
if (!parsedOffset.valid) return c.json({ error: parsedOffset.error }, 400);
const limit = parsedLimit.value ?? 100;
const offset = parsedOffset.value ?? 0;

const projectId = c.req.query('projectId');
let rootPath: string | undefined;
Expand All @@ -103,7 +127,7 @@ graphRoutes.get('/api/graph/files', async (c) => {
}

const client = await getGraphClient();
const data = await createQueries(client).getFileGraph(parsedLimit.value ?? 100, rootPath);
const data = await createQueries(client).getFileGraph(limit, rootPath, offset);
return c.json(data);
} catch (error) {
return c.json({
Expand All @@ -112,6 +136,47 @@ graphRoutes.get('/api/graph/files', async (c) => {
}
});

/** POST /api/graph/induced-edges returns public edges among the requested persisted node ids. */
graphRoutes.post('/api/graph/induced-edges', async (c) => {
try {
const body: unknown = await c.req.json().catch(() => undefined);
if (
body === null
|| typeof body !== 'object'
|| !('ids' in body)
|| !Array.isArray(body.ids)
|| !body.ids.every((id): id is string => typeof id === 'string')
) {
return c.json({ error: 'body must be an object with an ids string array' }, 400);
}
if (body.ids.length > INDUCED_EDGE_IDS_MAX) {
return c.json({ error: `ids must contain at most ${INDUCED_EDGE_IDS_MAX} items` }, 400);
}

const projectId = c.req.query('projectId');
let rootPath: string | undefined;
if (projectId) {
const resolvedRootPath = await resolveProjectRootPath(projectId);
if (!resolvedRootPath) return c.json({ error: 'Project not found' }, 404);
rootPath = resolvedRootPath;
}

const client = await getGraphClient();
const edges = await createQueries(client).getInducedEdges(body.ids, rootPath);
return c.json({
edges: edges.map(({ source, target, label }) => ({ source, target, label })),
});
} catch (error) {
return c.json({
error: safeErrorMessage(
'POST /api/graph/induced-edges',
error,
'Failed to fetch induced graph edges.',
),
}, 500);
}
});

/** GET /api/graph/neighbors?id=X&limit=N - direct neighbors and their induced graph. */
graphRoutes.get('/api/graph/neighbors', async (c) => {
try {
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/__tests__/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,19 @@ describe('CodeGraphService', () => {
mockClient.roQuery.mockResolvedValue({ data: [], metadata: null });
});

describe('getFullGraph', () => {
it('forwards the requested offset into the ordered graph query', async () => {
await codeGraphService.getFullGraph(25, '/repo', 3000);

const nodeQuery = mockClient.roQuery.mock.calls.find(([cypher]) => (
typeof cypher === 'string' && cypher.includes('ORDER BY degree DESC, stableId ASC')
));
expect(nodeQuery?.[1]).toEqual({
params: { limit: 25, offset: 3000, rootPath: '/repo', rootPathPrefix: '/repo/' },
});
});
});

// =========================================================================
// deleteProject
// =========================================================================
Expand Down
Loading
Loading