@utopia-ai/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.json +1 -0
- package/.claude/settings.local.json +38 -0
- package/bin/utopia.js +20 -0
- package/package.json +46 -0
- package/python/README.md +34 -0
- package/python/instrumenter/instrument.py +1148 -0
- package/python/pyproject.toml +32 -0
- package/python/setup.py +27 -0
- package/python/utopia_runtime/__init__.py +30 -0
- package/python/utopia_runtime/__pycache__/__init__.cpython-313.pyc +0 -0
- package/python/utopia_runtime/__pycache__/client.cpython-313.pyc +0 -0
- package/python/utopia_runtime/__pycache__/probe.cpython-313.pyc +0 -0
- package/python/utopia_runtime/client.py +31 -0
- package/python/utopia_runtime/probe.py +446 -0
- package/python/utopia_runtime.egg-info/PKG-INFO +59 -0
- package/python/utopia_runtime.egg-info/SOURCES.txt +10 -0
- package/python/utopia_runtime.egg-info/dependency_links.txt +1 -0
- package/python/utopia_runtime.egg-info/top_level.txt +1 -0
- package/scripts/publish-npm.sh +14 -0
- package/scripts/publish-pypi.sh +17 -0
- package/src/cli/commands/codex.ts +193 -0
- package/src/cli/commands/context.ts +188 -0
- package/src/cli/commands/destruct.ts +237 -0
- package/src/cli/commands/easter-eggs.ts +203 -0
- package/src/cli/commands/init.ts +505 -0
- package/src/cli/commands/instrument.ts +962 -0
- package/src/cli/commands/mcp.ts +16 -0
- package/src/cli/commands/serve.ts +194 -0
- package/src/cli/commands/status.ts +304 -0
- package/src/cli/commands/validate.ts +328 -0
- package/src/cli/index.ts +37 -0
- package/src/cli/utils/config.ts +54 -0
- package/src/graph/index.ts +687 -0
- package/src/instrumenter/javascript.ts +1798 -0
- package/src/mcp/index.ts +886 -0
- package/src/runtime/js/index.ts +518 -0
- package/src/runtime/js/package-lock.json +30 -0
- package/src/runtime/js/package.json +30 -0
- package/src/runtime/js/tsconfig.json +16 -0
- package/src/server/db/index.ts +26 -0
- package/src/server/db/schema.ts +45 -0
- package/src/server/index.ts +79 -0
- package/src/server/middleware/auth.ts +74 -0
- package/src/server/routes/admin.ts +36 -0
- package/src/server/routes/graph.ts +358 -0
- package/src/server/routes/probes.ts +286 -0
- package/src/types.ts +147 -0
- package/src/utopia-mode/index.ts +206 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import express, { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import { initDb, closeDb } from './db/index.js';
|
|
3
|
+
import probesRouter from './routes/probes.js';
|
|
4
|
+
import graphRouter from './routes/graph.js';
|
|
5
|
+
import adminRouter from './routes/admin.js';
|
|
6
|
+
|
|
7
|
+
function corsMiddleware(req: Request, res: Response, next: NextFunction): void {
|
|
8
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
9
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
|
|
10
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
11
|
+
res.setHeader('Access-Control-Max-Age', '86400');
|
|
12
|
+
if (req.method === 'OPTIONS') { res.status(204).end(); return; }
|
|
13
|
+
next();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createApp(dbPath: string): express.Application {
|
|
17
|
+
initDb(dbPath);
|
|
18
|
+
const app = express();
|
|
19
|
+
app.use(corsMiddleware);
|
|
20
|
+
app.use(express.json({ limit: '10mb' }));
|
|
21
|
+
|
|
22
|
+
app.get('/api/v1/health', (_req: Request, res: Response) => {
|
|
23
|
+
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// No auth — local only
|
|
27
|
+
app.use('/api/v1/probes', probesRouter);
|
|
28
|
+
app.use('/api/v1/graph', graphRouter);
|
|
29
|
+
app.use('/api/v1/admin', adminRouter);
|
|
30
|
+
|
|
31
|
+
app.use((_req: Request, res: Response) => {
|
|
32
|
+
res.status(404).json({ error: 'Not found' });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
|
36
|
+
console.error(`[utopia-server] Error: ${err.message}`);
|
|
37
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return app;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function startServer(
|
|
44
|
+
port: number,
|
|
45
|
+
dbPath: string,
|
|
46
|
+
): { app: express.Application; close: () => void } {
|
|
47
|
+
const app = createApp(dbPath);
|
|
48
|
+
|
|
49
|
+
const server = app.listen(port, () => {
|
|
50
|
+
console.log(`[utopia-server] Listening on port ${port}`);
|
|
51
|
+
console.log(`[utopia-server] Database: ${dbPath}`);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
server.on('error', (err: NodeJS.ErrnoException) => {
|
|
55
|
+
if (err.code === 'EADDRINUSE') {
|
|
56
|
+
console.error(`[utopia-server] Port ${port} is already in use.`);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
throw err;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const close = () => { server.close(); closeDb(); };
|
|
63
|
+
|
|
64
|
+
process.on('SIGINT', () => { console.log('\n[utopia-server] Shutting down...'); close(); process.exit(0); });
|
|
65
|
+
process.on('SIGTERM', () => { close(); process.exit(0); });
|
|
66
|
+
|
|
67
|
+
return { app, close };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const isMainModule = process.argv[1] && (
|
|
71
|
+
process.argv[1].endsWith('/server/index.ts') ||
|
|
72
|
+
process.argv[1].endsWith('/server/index.js')
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (isMainModule) {
|
|
76
|
+
const port = parseInt(process.env.UTOPIA_PORT || '7890', 10);
|
|
77
|
+
const dbPath = process.env.UTOPIA_DB_PATH || './utopia.db';
|
|
78
|
+
startServer(port, dbPath);
|
|
79
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import { getDb } from '../db/index.js';
|
|
4
|
+
|
|
5
|
+
export function hashApiKey(key: string): string {
|
|
6
|
+
return crypto.createHash('sha256').update(key).digest('hex');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
|
10
|
+
const apiKey = req.headers['x-api-key'] as string;
|
|
11
|
+
if (!apiKey) {
|
|
12
|
+
res.status(401).json({ error: 'Missing X-API-Key header' });
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const keyHash = hashApiKey(apiKey);
|
|
17
|
+
const db = getDb();
|
|
18
|
+
const row = db.prepare(
|
|
19
|
+
'SELECT id, key_hash, project_id FROM api_keys WHERE key_hash = ?'
|
|
20
|
+
).get(keyHash) as { id: string; key_hash: string; project_id: string } | undefined;
|
|
21
|
+
|
|
22
|
+
if (!row) {
|
|
23
|
+
res.status(401).json({ error: 'Invalid API key' });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Timing-safe comparison to prevent timing attacks
|
|
28
|
+
const valid = crypto.timingSafeEqual(
|
|
29
|
+
Buffer.from(row.key_hash),
|
|
30
|
+
Buffer.from(keyHash)
|
|
31
|
+
);
|
|
32
|
+
if (!valid) {
|
|
33
|
+
res.status(401).json({ error: 'Invalid API key' });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Update last_used timestamp
|
|
38
|
+
db.prepare("UPDATE api_keys SET last_used = datetime('now') WHERE id = ?").run(row.id);
|
|
39
|
+
|
|
40
|
+
// Attach project_id to request for downstream use
|
|
41
|
+
(req as any).projectId = row.project_id;
|
|
42
|
+
next();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function masterKeyMiddleware(req: Request, res: Response, next: NextFunction): void {
|
|
46
|
+
const masterKey = process.env.UTOPIA_MASTER_KEY;
|
|
47
|
+
if (!masterKey) {
|
|
48
|
+
res.status(500).json({ error: 'UTOPIA_MASTER_KEY not configured' });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const provided = req.headers['x-master-key'] as string;
|
|
53
|
+
if (!provided) {
|
|
54
|
+
res.status(401).json({ error: 'Missing X-Master-Key header' });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Ensure lengths match before timing-safe comparison
|
|
59
|
+
if (provided.length !== masterKey.length) {
|
|
60
|
+
res.status(401).json({ error: 'Invalid master key' });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const valid = crypto.timingSafeEqual(
|
|
65
|
+
Buffer.from(masterKey),
|
|
66
|
+
Buffer.from(provided)
|
|
67
|
+
);
|
|
68
|
+
if (!valid) {
|
|
69
|
+
res.status(401).json({ error: 'Invalid master key' });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
next();
|
|
74
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Router, Request, Response } from 'express';
|
|
2
|
+
import { getDb } from '../db/index.js';
|
|
3
|
+
|
|
4
|
+
const router: Router = Router();
|
|
5
|
+
|
|
6
|
+
// GET /stats - Return aggregate statistics
|
|
7
|
+
router.get('/stats', (_req: Request, res: Response) => {
|
|
8
|
+
const db = getDb();
|
|
9
|
+
|
|
10
|
+
const totalProbes = (db.prepare('SELECT COUNT(*) as count FROM probes').get() as { count: number }).count;
|
|
11
|
+
|
|
12
|
+
const probesByType = db.prepare(
|
|
13
|
+
'SELECT probe_type, COUNT(*) as count FROM probes GROUP BY probe_type'
|
|
14
|
+
).all() as Array<{ probe_type: string; count: number }>;
|
|
15
|
+
|
|
16
|
+
const probesByTypeMap: Record<string, number> = {};
|
|
17
|
+
for (const row of probesByType) {
|
|
18
|
+
probesByTypeMap[row.probe_type] = row.count;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const graphNodesCount = (db.prepare('SELECT COUNT(*) as count FROM graph_nodes').get() as { count: number }).count;
|
|
22
|
+
const graphEdgesCount = (db.prepare('SELECT COUNT(*) as count FROM graph_edges').get() as { count: number }).count;
|
|
23
|
+
|
|
24
|
+
res.json({
|
|
25
|
+
probes: {
|
|
26
|
+
total: totalProbes,
|
|
27
|
+
byType: probesByTypeMap,
|
|
28
|
+
},
|
|
29
|
+
graph: {
|
|
30
|
+
nodes: graphNodesCount,
|
|
31
|
+
edges: graphEdgesCount,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export default router;
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { Router, Request, Response } from 'express';
|
|
2
|
+
import { getDb } from '../db/index.js';
|
|
3
|
+
|
|
4
|
+
const router: Router = Router();
|
|
5
|
+
|
|
6
|
+
interface NodeRow {
|
|
7
|
+
id: string;
|
|
8
|
+
type: string;
|
|
9
|
+
name: string;
|
|
10
|
+
file: string | null;
|
|
11
|
+
metadata: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface EdgeRow {
|
|
15
|
+
source: string;
|
|
16
|
+
target: string;
|
|
17
|
+
type: string;
|
|
18
|
+
weight: number;
|
|
19
|
+
last_seen: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface NodeResponse {
|
|
23
|
+
id: string;
|
|
24
|
+
type: string;
|
|
25
|
+
name: string;
|
|
26
|
+
file: string | null;
|
|
27
|
+
metadata: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface EdgeResponse {
|
|
31
|
+
source: string;
|
|
32
|
+
target: string;
|
|
33
|
+
type: string;
|
|
34
|
+
weight: number;
|
|
35
|
+
lastSeen: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function rowToNode(row: NodeRow): NodeResponse {
|
|
39
|
+
return {
|
|
40
|
+
id: row.id,
|
|
41
|
+
type: row.type,
|
|
42
|
+
name: row.name,
|
|
43
|
+
file: row.file,
|
|
44
|
+
metadata: JSON.parse(row.metadata),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function rowToEdge(row: EdgeRow): EdgeResponse {
|
|
49
|
+
return {
|
|
50
|
+
source: row.source,
|
|
51
|
+
target: row.target,
|
|
52
|
+
type: row.type,
|
|
53
|
+
weight: row.weight,
|
|
54
|
+
lastSeen: row.last_seen,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const VALID_NODE_TYPES = new Set(['function', 'service', 'database', 'api', 'file']);
|
|
59
|
+
const VALID_EDGE_TYPES = new Set(['calls', 'queries', 'serves', 'depends_on']);
|
|
60
|
+
|
|
61
|
+
// POST /nodes - Upsert graph nodes (single or array)
|
|
62
|
+
router.post('/nodes', (req: Request, res: Response) => {
|
|
63
|
+
const db = getDb();
|
|
64
|
+
const body = req.body;
|
|
65
|
+
const nodes: Record<string, unknown>[] = Array.isArray(body) ? body : [body];
|
|
66
|
+
|
|
67
|
+
if (nodes.length === 0) {
|
|
68
|
+
res.status(400).json({ error: 'Request body must contain node data' });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const errors: Array<{ index: number; error: string }> = [];
|
|
73
|
+
|
|
74
|
+
const upsertStmt = db.prepare(`
|
|
75
|
+
INSERT INTO graph_nodes (id, type, name, file, metadata)
|
|
76
|
+
VALUES (?, ?, ?, ?, ?)
|
|
77
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
78
|
+
type = excluded.type,
|
|
79
|
+
name = excluded.name,
|
|
80
|
+
file = excluded.file,
|
|
81
|
+
metadata = excluded.metadata
|
|
82
|
+
`);
|
|
83
|
+
|
|
84
|
+
const upsertMany = db.transaction((nodeList: Record<string, unknown>[]) => {
|
|
85
|
+
let upserted = 0;
|
|
86
|
+
for (let i = 0; i < nodeList.length; i++) {
|
|
87
|
+
const node = nodeList[i];
|
|
88
|
+
|
|
89
|
+
if (!node.id || typeof node.id !== 'string') {
|
|
90
|
+
errors.push({ index: i, error: 'Missing or invalid "id"' });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!node.type || !VALID_NODE_TYPES.has(node.type as string)) {
|
|
94
|
+
errors.push({ index: i, error: `Invalid "type". Must be one of: ${[...VALID_NODE_TYPES].join(', ')}` });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!node.name || typeof node.name !== 'string') {
|
|
98
|
+
errors.push({ index: i, error: 'Missing or invalid "name"' });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
upsertStmt.run(
|
|
103
|
+
node.id,
|
|
104
|
+
node.type,
|
|
105
|
+
node.name,
|
|
106
|
+
(node.file as string) ?? null,
|
|
107
|
+
JSON.stringify(node.metadata ?? {}),
|
|
108
|
+
);
|
|
109
|
+
upserted++;
|
|
110
|
+
}
|
|
111
|
+
return upserted;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const upserted = upsertMany(nodes);
|
|
115
|
+
|
|
116
|
+
if (errors.length > 0 && upserted === 0) {
|
|
117
|
+
res.status(400).json({ error: 'All nodes failed validation', details: errors });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
res.status(201).json({
|
|
122
|
+
upserted,
|
|
123
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// POST /edges - Upsert graph edges (increment weight on conflict)
|
|
128
|
+
router.post('/edges', (req: Request, res: Response) => {
|
|
129
|
+
const db = getDb();
|
|
130
|
+
const body = req.body;
|
|
131
|
+
const edges: Record<string, unknown>[] = Array.isArray(body) ? body : [body];
|
|
132
|
+
|
|
133
|
+
if (edges.length === 0) {
|
|
134
|
+
res.status(400).json({ error: 'Request body must contain edge data' });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const errors: Array<{ index: number; error: string }> = [];
|
|
139
|
+
|
|
140
|
+
const upsertStmt = db.prepare(`
|
|
141
|
+
INSERT INTO graph_edges (source, target, type, weight, last_seen)
|
|
142
|
+
VALUES (?, ?, ?, 1, datetime('now'))
|
|
143
|
+
ON CONFLICT(source, target, type) DO UPDATE SET
|
|
144
|
+
weight = graph_edges.weight + 1,
|
|
145
|
+
last_seen = datetime('now')
|
|
146
|
+
`);
|
|
147
|
+
|
|
148
|
+
const upsertMany = db.transaction((edgeList: Record<string, unknown>[]) => {
|
|
149
|
+
let upserted = 0;
|
|
150
|
+
for (let i = 0; i < edgeList.length; i++) {
|
|
151
|
+
const edge = edgeList[i];
|
|
152
|
+
|
|
153
|
+
if (!edge.source || typeof edge.source !== 'string') {
|
|
154
|
+
errors.push({ index: i, error: 'Missing or invalid "source"' });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (!edge.target || typeof edge.target !== 'string') {
|
|
158
|
+
errors.push({ index: i, error: 'Missing or invalid "target"' });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (!edge.type || !VALID_EDGE_TYPES.has(edge.type as string)) {
|
|
162
|
+
errors.push({ index: i, error: `Invalid "type". Must be one of: ${[...VALID_EDGE_TYPES].join(', ')}` });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Verify that source and target nodes exist
|
|
167
|
+
const sourceExists = db.prepare('SELECT id FROM graph_nodes WHERE id = ?').get(edge.source);
|
|
168
|
+
const targetExists = db.prepare('SELECT id FROM graph_nodes WHERE id = ?').get(edge.target);
|
|
169
|
+
|
|
170
|
+
if (!sourceExists) {
|
|
171
|
+
errors.push({ index: i, error: `Source node "${edge.source}" does not exist` });
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (!targetExists) {
|
|
175
|
+
errors.push({ index: i, error: `Target node "${edge.target}" does not exist` });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
upsertStmt.run(edge.source, edge.target, edge.type);
|
|
180
|
+
upserted++;
|
|
181
|
+
}
|
|
182
|
+
return upserted;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const upserted = upsertMany(edges);
|
|
186
|
+
|
|
187
|
+
if (errors.length > 0 && upserted === 0) {
|
|
188
|
+
res.status(400).json({ error: 'All edges failed validation', details: errors });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
res.status(201).json({
|
|
193
|
+
upserted,
|
|
194
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// GET / - Get full graph or filter by node type
|
|
199
|
+
router.get('/', (req: Request, res: Response) => {
|
|
200
|
+
const db = getDb();
|
|
201
|
+
const nodeType = req.query.type as string | undefined;
|
|
202
|
+
|
|
203
|
+
let nodes: NodeRow[];
|
|
204
|
+
let edges: EdgeRow[];
|
|
205
|
+
|
|
206
|
+
if (nodeType) {
|
|
207
|
+
if (!VALID_NODE_TYPES.has(nodeType)) {
|
|
208
|
+
res.status(400).json({
|
|
209
|
+
error: `Invalid node type "${nodeType}". Must be one of: ${[...VALID_NODE_TYPES].join(', ')}`,
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
nodes = db.prepare('SELECT * FROM graph_nodes WHERE type = ?').all(nodeType) as NodeRow[];
|
|
215
|
+
|
|
216
|
+
// Get edges where both source and target are in the filtered node set
|
|
217
|
+
const nodeIds = nodes.map(n => n.id);
|
|
218
|
+
if (nodeIds.length === 0) {
|
|
219
|
+
res.json({ nodes: [], edges: [] });
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const placeholders = nodeIds.map(() => '?').join(',');
|
|
224
|
+
edges = db.prepare(
|
|
225
|
+
`SELECT * FROM graph_edges WHERE source IN (${placeholders}) OR target IN (${placeholders})`
|
|
226
|
+
).all(...nodeIds, ...nodeIds) as EdgeRow[];
|
|
227
|
+
} else {
|
|
228
|
+
nodes = db.prepare('SELECT * FROM graph_nodes').all() as NodeRow[];
|
|
229
|
+
edges = db.prepare('SELECT * FROM graph_edges').all() as EdgeRow[];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
res.json({
|
|
233
|
+
nodes: nodes.map(rowToNode),
|
|
234
|
+
edges: edges.map(rowToEdge),
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// GET /impact/:nodeId - BFS traversal outward from node, depth limit 5
|
|
239
|
+
router.get('/impact/:nodeId', (req: Request, res: Response) => {
|
|
240
|
+
const db = getDb();
|
|
241
|
+
const startNodeId = req.params.nodeId as string;
|
|
242
|
+
const maxDepth = Math.min(parseInt(req.query.depth as string, 10) || 5, 10);
|
|
243
|
+
|
|
244
|
+
// Verify start node exists
|
|
245
|
+
const startNode = db.prepare('SELECT * FROM graph_nodes WHERE id = ?').get(startNodeId) as NodeRow | undefined;
|
|
246
|
+
if (!startNode) {
|
|
247
|
+
res.status(404).json({ error: `Node "${startNodeId}" not found` });
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const visitedNodes = new Set<string>([startNodeId]);
|
|
252
|
+
const collectedEdges: EdgeRow[] = [];
|
|
253
|
+
let frontier: string[] = [startNodeId];
|
|
254
|
+
|
|
255
|
+
const edgeQuery = db.prepare(
|
|
256
|
+
'SELECT * FROM graph_edges WHERE source = ?'
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) {
|
|
260
|
+
const nextFrontier: string[] = [];
|
|
261
|
+
|
|
262
|
+
for (const nodeId of frontier) {
|
|
263
|
+
const edges = edgeQuery.all(nodeId) as EdgeRow[];
|
|
264
|
+
for (const edge of edges) {
|
|
265
|
+
collectedEdges.push(edge);
|
|
266
|
+
if (!visitedNodes.has(edge.target)) {
|
|
267
|
+
visitedNodes.add(edge.target);
|
|
268
|
+
nextFrontier.push(edge.target);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
frontier = nextFrontier;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Fetch all visited node details
|
|
277
|
+
const nodeIds = [...visitedNodes];
|
|
278
|
+
const placeholders = nodeIds.map(() => '?').join(',');
|
|
279
|
+
const nodes = db.prepare(
|
|
280
|
+
`SELECT * FROM graph_nodes WHERE id IN (${placeholders})`
|
|
281
|
+
).all(...nodeIds) as NodeRow[];
|
|
282
|
+
|
|
283
|
+
// Deduplicate edges by (source, target, type) key
|
|
284
|
+
const edgeMap = new Map<string, EdgeRow>();
|
|
285
|
+
for (const edge of collectedEdges) {
|
|
286
|
+
const key = `${edge.source}|${edge.target}|${edge.type}`;
|
|
287
|
+
edgeMap.set(key, edge);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
res.json({
|
|
291
|
+
startNode: startNodeId,
|
|
292
|
+
depth: maxDepth,
|
|
293
|
+
nodes: nodes.map(rowToNode),
|
|
294
|
+
edges: [...edgeMap.values()].map(rowToEdge),
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// GET /dependencies/:nodeId - BFS traversal inward (reverse edges) to find dependencies, depth limit 5
|
|
299
|
+
router.get('/dependencies/:nodeId', (req: Request, res: Response) => {
|
|
300
|
+
const db = getDb();
|
|
301
|
+
const startNodeId = req.params.nodeId as string;
|
|
302
|
+
const maxDepth = Math.min(parseInt(req.query.depth as string, 10) || 5, 10);
|
|
303
|
+
|
|
304
|
+
// Verify start node exists
|
|
305
|
+
const startNode = db.prepare('SELECT * FROM graph_nodes WHERE id = ?').get(startNodeId) as NodeRow | undefined;
|
|
306
|
+
if (!startNode) {
|
|
307
|
+
res.status(404).json({ error: `Node "${startNodeId}" not found` });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const visitedNodes = new Set<string>([startNodeId]);
|
|
312
|
+
const collectedEdges: EdgeRow[] = [];
|
|
313
|
+
let frontier: string[] = [startNodeId];
|
|
314
|
+
|
|
315
|
+
const edgeQuery = db.prepare(
|
|
316
|
+
'SELECT * FROM graph_edges WHERE target = ?'
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) {
|
|
320
|
+
const nextFrontier: string[] = [];
|
|
321
|
+
|
|
322
|
+
for (const nodeId of frontier) {
|
|
323
|
+
const edges = edgeQuery.all(nodeId) as EdgeRow[];
|
|
324
|
+
for (const edge of edges) {
|
|
325
|
+
collectedEdges.push(edge);
|
|
326
|
+
if (!visitedNodes.has(edge.source)) {
|
|
327
|
+
visitedNodes.add(edge.source);
|
|
328
|
+
nextFrontier.push(edge.source);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
frontier = nextFrontier;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Fetch all visited node details
|
|
337
|
+
const nodeIds = [...visitedNodes];
|
|
338
|
+
const placeholders = nodeIds.map(() => '?').join(',');
|
|
339
|
+
const nodes = db.prepare(
|
|
340
|
+
`SELECT * FROM graph_nodes WHERE id IN (${placeholders})`
|
|
341
|
+
).all(...nodeIds) as NodeRow[];
|
|
342
|
+
|
|
343
|
+
// Deduplicate edges
|
|
344
|
+
const edgeMap = new Map<string, EdgeRow>();
|
|
345
|
+
for (const edge of collectedEdges) {
|
|
346
|
+
const key = `${edge.source}|${edge.target}|${edge.type}`;
|
|
347
|
+
edgeMap.set(key, edge);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
res.json({
|
|
351
|
+
startNode: startNodeId,
|
|
352
|
+
depth: maxDepth,
|
|
353
|
+
nodes: nodes.map(rowToNode),
|
|
354
|
+
edges: [...edgeMap.values()].map(rowToEdge),
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
export default router;
|