node-documentation-service.ts•23.7 kB
import { createHash } from 'crypto';
import path from 'path';
import { promises as fs } from 'fs';
import { logger } from '../utils/logger';
import { NodeSourceExtractor } from '../utils/node-source-extractor';
import {
EnhancedDocumentationFetcher,
EnhancedNodeDocumentation,
OperationInfo,
ApiMethodMapping,
CodeExample,
TemplateInfo,
RelatedResource
} from '../utils/enhanced-documentation-fetcher';
import { ExampleGenerator } from '../utils/example-generator';
import { DatabaseAdapter, createDatabaseAdapter } from '../database/database-adapter';
interface NodeInfo {
nodeType: string;
name: string;
displayName: string;
description: string;
category?: string;
subcategory?: string;
icon?: string;
sourceCode: string;
credentialCode?: string;
documentationMarkdown?: string;
documentationUrl?: string;
documentationTitle?: string;
operations?: OperationInfo[];
apiMethods?: ApiMethodMapping[];
documentationExamples?: CodeExample[];
templates?: TemplateInfo[];
relatedResources?: RelatedResource[];
requiredScopes?: string[];
exampleWorkflow?: any;
exampleParameters?: any;
propertiesSchema?: any;
packageName: string;
version?: string;
codexData?: any;
aliases?: string[];
hasCredentials: boolean;
isTrigger: boolean;
isWebhook: boolean;
}
interface SearchOptions {
query?: string;
nodeType?: string;
packageName?: string;
category?: string;
hasCredentials?: boolean;
isTrigger?: boolean;
limit?: number;
}
export class NodeDocumentationService {
private db: DatabaseAdapter | null = null;
private extractor: NodeSourceExtractor;
private docsFetcher: EnhancedDocumentationFetcher;
private dbPath: string;
private initialized: Promise<void>;
constructor(dbPath?: string) {
// Determine database path with multiple fallbacks for npx support
this.dbPath = dbPath || process.env.NODE_DB_PATH || this.findDatabasePath();
// Ensure directory exists
const dbDir = path.dirname(this.dbPath);
if (!require('fs').existsSync(dbDir)) {
require('fs').mkdirSync(dbDir, { recursive: true });
}
this.extractor = new NodeSourceExtractor();
this.docsFetcher = new EnhancedDocumentationFetcher();
// Initialize database asynchronously
this.initialized = this.initializeAsync();
}
private findDatabasePath(): string {
const fs = require('fs');
// Priority order for database locations:
// 1. Local working directory (current behavior)
const localPath = path.join(process.cwd(), 'data', 'nodes.db');
if (fs.existsSync(localPath)) {
return localPath;
}
// 2. Package installation directory (for npx)
const packagePath = path.join(__dirname, '..', '..', 'data', 'nodes.db');
if (fs.existsSync(packagePath)) {
return packagePath;
}
// 3. Global npm modules directory (for global install)
const globalPath = path.join(__dirname, '..', '..', '..', 'data', 'nodes.db');
if (fs.existsSync(globalPath)) {
return globalPath;
}
// 4. Default to local path (will be created if needed)
return localPath;
}
private async initializeAsync(): Promise<void> {
try {
this.db = await createDatabaseAdapter(this.dbPath);
// Initialize database with new schema
this.initializeDatabase();
logger.info('Node Documentation Service initialized');
} catch (error) {
logger.error('Failed to initialize database adapter', error);
throw error;
}
}
private async ensureInitialized(): Promise<void> {
await this.initialized;
if (!this.db) {
throw new Error('Database not initialized');
}
}
private initializeDatabase(): void {
if (!this.db) throw new Error('Database not initialized');
// Execute the schema directly
const schema = `
-- Main nodes table with documentation and examples
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_type TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
display_name TEXT,
description TEXT,
category TEXT,
subcategory TEXT,
icon TEXT,
-- Source code
source_code TEXT NOT NULL,
credential_code TEXT,
code_hash TEXT NOT NULL,
code_length INTEGER NOT NULL,
-- Documentation
documentation_markdown TEXT,
documentation_url TEXT,
documentation_title TEXT,
-- Enhanced documentation fields (stored as JSON)
operations TEXT,
api_methods TEXT,
documentation_examples TEXT,
templates TEXT,
related_resources TEXT,
required_scopes TEXT,
-- Example usage
example_workflow TEXT,
example_parameters TEXT,
properties_schema TEXT,
-- Metadata
package_name TEXT NOT NULL,
version TEXT,
codex_data TEXT,
aliases TEXT,
-- Flags
has_credentials INTEGER DEFAULT 0,
is_trigger INTEGER DEFAULT 0,
is_webhook INTEGER DEFAULT 0,
-- Timestamps
extracted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_nodes_package_name ON nodes(package_name);
CREATE INDEX IF NOT EXISTS idx_nodes_category ON nodes(category);
CREATE INDEX IF NOT EXISTS idx_nodes_code_hash ON nodes(code_hash);
CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
CREATE INDEX IF NOT EXISTS idx_nodes_is_trigger ON nodes(is_trigger);
-- Full Text Search
CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
node_type,
name,
display_name,
description,
category,
documentation_markdown,
aliases,
content=nodes,
content_rowid=id
);
-- Triggers for FTS
CREATE TRIGGER IF NOT EXISTS nodes_ai AFTER INSERT ON nodes
BEGIN
INSERT INTO nodes_fts(rowid, node_type, name, display_name, description, category, documentation_markdown, aliases)
VALUES (new.id, new.node_type, new.name, new.display_name, new.description, new.category, new.documentation_markdown, new.aliases);
END;
CREATE TRIGGER IF NOT EXISTS nodes_ad AFTER DELETE ON nodes
BEGIN
DELETE FROM nodes_fts WHERE rowid = old.id;
END;
CREATE TRIGGER IF NOT EXISTS nodes_au AFTER UPDATE ON nodes
BEGIN
DELETE FROM nodes_fts WHERE rowid = old.id;
INSERT INTO nodes_fts(rowid, node_type, name, display_name, description, category, documentation_markdown, aliases)
VALUES (new.id, new.node_type, new.name, new.display_name, new.description, new.category, new.documentation_markdown, new.aliases);
END;
-- Documentation sources table
CREATE TABLE IF NOT EXISTS documentation_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
commit_hash TEXT,
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Statistics table
CREATE TABLE IF NOT EXISTS extraction_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
total_nodes INTEGER NOT NULL,
nodes_with_docs INTEGER NOT NULL,
nodes_with_examples INTEGER NOT NULL,
total_code_size INTEGER NOT NULL,
total_docs_size INTEGER NOT NULL,
extraction_date DATETIME DEFAULT CURRENT_TIMESTAMP
);
`;
this.db!.exec(schema);
}
/**
* Store complete node information including docs and examples
*/
async storeNode(nodeInfo: NodeInfo): Promise<void> {
await this.ensureInitialized();
const hash = this.generateHash(nodeInfo.sourceCode);
const stmt = this.db!.prepare(`
INSERT OR REPLACE INTO nodes (
node_type, name, display_name, description, category, subcategory, icon,
source_code, credential_code, code_hash, code_length,
documentation_markdown, documentation_url, documentation_title,
operations, api_methods, documentation_examples, templates, related_resources, required_scopes,
example_workflow, example_parameters, properties_schema,
package_name, version, codex_data, aliases,
has_credentials, is_trigger, is_webhook
) VALUES (
@nodeType, @name, @displayName, @description, @category, @subcategory, @icon,
@sourceCode, @credentialCode, @hash, @codeLength,
@documentation, @documentationUrl, @documentationTitle,
@operations, @apiMethods, @documentationExamples, @templates, @relatedResources, @requiredScopes,
@exampleWorkflow, @exampleParameters, @propertiesSchema,
@packageName, @version, @codexData, @aliases,
@hasCredentials, @isTrigger, @isWebhook
)
`);
stmt.run({
nodeType: nodeInfo.nodeType,
name: nodeInfo.name,
displayName: nodeInfo.displayName || nodeInfo.name,
description: nodeInfo.description || '',
category: nodeInfo.category || 'Other',
subcategory: nodeInfo.subcategory || null,
icon: nodeInfo.icon || null,
sourceCode: nodeInfo.sourceCode,
credentialCode: nodeInfo.credentialCode || null,
hash,
codeLength: nodeInfo.sourceCode.length,
documentation: nodeInfo.documentationMarkdown || null,
documentationUrl: nodeInfo.documentationUrl || null,
documentationTitle: nodeInfo.documentationTitle || null,
operations: nodeInfo.operations ? JSON.stringify(nodeInfo.operations) : null,
apiMethods: nodeInfo.apiMethods ? JSON.stringify(nodeInfo.apiMethods) : null,
documentationExamples: nodeInfo.documentationExamples ? JSON.stringify(nodeInfo.documentationExamples) : null,
templates: nodeInfo.templates ? JSON.stringify(nodeInfo.templates) : null,
relatedResources: nodeInfo.relatedResources ? JSON.stringify(nodeInfo.relatedResources) : null,
requiredScopes: nodeInfo.requiredScopes ? JSON.stringify(nodeInfo.requiredScopes) : null,
exampleWorkflow: nodeInfo.exampleWorkflow ? JSON.stringify(nodeInfo.exampleWorkflow) : null,
exampleParameters: nodeInfo.exampleParameters ? JSON.stringify(nodeInfo.exampleParameters) : null,
propertiesSchema: nodeInfo.propertiesSchema ? JSON.stringify(nodeInfo.propertiesSchema) : null,
packageName: nodeInfo.packageName,
version: nodeInfo.version || null,
codexData: nodeInfo.codexData ? JSON.stringify(nodeInfo.codexData) : null,
aliases: nodeInfo.aliases ? JSON.stringify(nodeInfo.aliases) : null,
hasCredentials: nodeInfo.hasCredentials ? 1 : 0,
isTrigger: nodeInfo.isTrigger ? 1 : 0,
isWebhook: nodeInfo.isWebhook ? 1 : 0
});
}
/**
* Get complete node information
*/
async getNodeInfo(nodeType: string): Promise<NodeInfo | null> {
await this.ensureInitialized();
const stmt = this.db!.prepare(`
SELECT * FROM nodes WHERE node_type = ? OR name = ? COLLATE NOCASE
`);
const row = stmt.get(nodeType, nodeType);
if (!row) return null;
return this.rowToNodeInfo(row);
}
/**
* Search nodes with various filters
*/
async searchNodes(options: SearchOptions): Promise<NodeInfo[]> {
await this.ensureInitialized();
let query = 'SELECT * FROM nodes WHERE 1=1';
const params: any = {};
if (options.query) {
query += ` AND id IN (
SELECT rowid FROM nodes_fts
WHERE nodes_fts MATCH @query
)`;
params.query = options.query;
}
if (options.nodeType) {
query += ' AND node_type LIKE @nodeType';
params.nodeType = `%${options.nodeType}%`;
}
if (options.packageName) {
query += ' AND package_name = @packageName';
params.packageName = options.packageName;
}
if (options.category) {
query += ' AND category = @category';
params.category = options.category;
}
if (options.hasCredentials !== undefined) {
query += ' AND has_credentials = @hasCredentials';
params.hasCredentials = options.hasCredentials ? 1 : 0;
}
if (options.isTrigger !== undefined) {
query += ' AND is_trigger = @isTrigger';
params.isTrigger = options.isTrigger ? 1 : 0;
}
query += ' ORDER BY name LIMIT @limit';
params.limit = options.limit || 20;
const stmt = this.db!.prepare(query);
const rows = stmt.all(params);
return rows.map(row => this.rowToNodeInfo(row));
}
/**
* List all nodes
*/
async listNodes(): Promise<NodeInfo[]> {
await this.ensureInitialized();
const stmt = this.db!.prepare('SELECT * FROM nodes ORDER BY name');
const rows = stmt.all();
return rows.map(row => this.rowToNodeInfo(row));
}
/**
* Extract and store all nodes with documentation
*/
async rebuildDatabase(): Promise<{
total: number;
successful: number;
failed: number;
errors: string[];
}> {
await this.ensureInitialized();
logger.info('Starting complete database rebuild...');
// Clear existing data
this.db!.exec('DELETE FROM nodes');
this.db!.exec('DELETE FROM extraction_stats');
// Ensure documentation repository is available
await this.docsFetcher.ensureDocsRepository();
const stats = {
total: 0,
successful: 0,
failed: 0,
errors: [] as string[]
};
try {
// Get all available nodes
const availableNodes = await this.extractor.listAvailableNodes();
stats.total = availableNodes.length;
logger.info(`Found ${stats.total} nodes to process`);
// Process nodes in batches
const batchSize = 10;
for (let i = 0; i < availableNodes.length; i += batchSize) {
const batch = availableNodes.slice(i, i + batchSize);
await Promise.all(batch.map(async (node) => {
try {
// Build node type from package name and node name
const nodeType = `n8n-nodes-base.${node.name}`;
// Extract source code
const nodeData = await this.extractor.extractNodeSource(nodeType);
if (!nodeData || !nodeData.sourceCode) {
throw new Error('Failed to extract node source');
}
// Parse node definition to get metadata
const nodeDefinition = this.parseNodeDefinition(nodeData.sourceCode);
// Get enhanced documentation
const enhancedDocs = await this.docsFetcher.getEnhancedNodeDocumentation(nodeType);
// Generate example
const example = ExampleGenerator.generateFromNodeDefinition(nodeDefinition);
// Prepare node info with enhanced documentation
const nodeInfo: NodeInfo = {
nodeType: nodeType,
name: node.name,
displayName: nodeDefinition.displayName || node.displayName || node.name,
description: nodeDefinition.description || node.description || '',
category: nodeDefinition.category || 'Other',
subcategory: nodeDefinition.subcategory,
icon: nodeDefinition.icon,
sourceCode: nodeData.sourceCode,
credentialCode: nodeData.credentialCode,
documentationMarkdown: enhancedDocs?.markdown,
documentationUrl: enhancedDocs?.url,
documentationTitle: enhancedDocs?.title,
operations: enhancedDocs?.operations,
apiMethods: enhancedDocs?.apiMethods,
documentationExamples: enhancedDocs?.examples,
templates: enhancedDocs?.templates,
relatedResources: enhancedDocs?.relatedResources,
requiredScopes: enhancedDocs?.requiredScopes,
exampleWorkflow: example,
exampleParameters: example.nodes[0]?.parameters,
propertiesSchema: nodeDefinition.properties,
packageName: nodeData.packageInfo?.name || 'n8n-nodes-base',
version: nodeDefinition.version,
codexData: nodeDefinition.codex,
aliases: nodeDefinition.alias,
hasCredentials: !!nodeData.credentialCode,
isTrigger: node.name.toLowerCase().includes('trigger'),
isWebhook: node.name.toLowerCase().includes('webhook')
};
// Store in database
await this.storeNode(nodeInfo);
stats.successful++;
logger.debug(`Processed node: ${nodeType}`);
} catch (error) {
stats.failed++;
const errorMsg = `Failed to process ${node.name}: ${error instanceof Error ? error.message : String(error)}`;
stats.errors.push(errorMsg);
logger.error(errorMsg);
}
}));
logger.info(`Progress: ${Math.min(i + batchSize, availableNodes.length)}/${stats.total} nodes processed`);
}
// Store statistics
this.storeStatistics(stats);
logger.info(`Database rebuild complete: ${stats.successful} successful, ${stats.failed} failed`);
} catch (error) {
logger.error('Database rebuild failed:', error);
throw error;
}
return stats;
}
/**
* Parse node definition from source code
*/
private parseNodeDefinition(sourceCode: string): any {
const result: any = {
displayName: '',
description: '',
properties: [],
category: null,
subcategory: null,
icon: null,
version: null,
codex: null,
alias: null
};
try {
// Extract individual properties using specific patterns
// Display name
const displayNameMatch = sourceCode.match(/displayName\s*[:=]\s*['"`]([^'"`]+)['"`]/);
if (displayNameMatch) {
result.displayName = displayNameMatch[1];
}
// Description
const descriptionMatch = sourceCode.match(/description\s*[:=]\s*['"`]([^'"`]+)['"`]/);
if (descriptionMatch) {
result.description = descriptionMatch[1];
}
// Icon
const iconMatch = sourceCode.match(/icon\s*[:=]\s*['"`]([^'"`]+)['"`]/);
if (iconMatch) {
result.icon = iconMatch[1];
}
// Category/group
const groupMatch = sourceCode.match(/group\s*[:=]\s*\[['"`]([^'"`]+)['"`]\]/);
if (groupMatch) {
result.category = groupMatch[1];
}
// Version
const versionMatch = sourceCode.match(/version\s*[:=]\s*(\d+)/);
if (versionMatch) {
result.version = parseInt(versionMatch[1]);
}
// Subtitle
const subtitleMatch = sourceCode.match(/subtitle\s*[:=]\s*['"`]([^'"`]+)['"`]/);
if (subtitleMatch) {
result.subtitle = subtitleMatch[1];
}
// Try to extract properties array
const propsMatch = sourceCode.match(/properties\s*[:=]\s*(\[[\s\S]*?\])\s*[,}]/);
if (propsMatch) {
try {
// This is complex to parse from minified code, so we'll skip for now
result.properties = [];
} catch (e) {
// Ignore parsing errors
}
}
// Check if it's a trigger node
if (sourceCode.includes('implements.*ITrigger') ||
sourceCode.includes('polling:.*true') ||
sourceCode.includes('webhook:.*true') ||
result.displayName.toLowerCase().includes('trigger')) {
result.isTrigger = true;
}
// Check if it's a webhook node
if (sourceCode.includes('webhooks:') ||
sourceCode.includes('webhook:.*true') ||
result.displayName.toLowerCase().includes('webhook')) {
result.isWebhook = true;
}
} catch (error) {
logger.debug('Error parsing node definition:', error);
}
return result;
}
/**
* Convert database row to NodeInfo
*/
private rowToNodeInfo(row: any): NodeInfo {
return {
nodeType: row.node_type,
name: row.name,
displayName: row.display_name,
description: row.description,
category: row.category,
subcategory: row.subcategory,
icon: row.icon,
sourceCode: row.source_code,
credentialCode: row.credential_code,
documentationMarkdown: row.documentation_markdown,
documentationUrl: row.documentation_url,
documentationTitle: row.documentation_title,
operations: row.operations ? JSON.parse(row.operations) : null,
apiMethods: row.api_methods ? JSON.parse(row.api_methods) : null,
documentationExamples: row.documentation_examples ? JSON.parse(row.documentation_examples) : null,
templates: row.templates ? JSON.parse(row.templates) : null,
relatedResources: row.related_resources ? JSON.parse(row.related_resources) : null,
requiredScopes: row.required_scopes ? JSON.parse(row.required_scopes) : null,
exampleWorkflow: row.example_workflow ? JSON.parse(row.example_workflow) : null,
exampleParameters: row.example_parameters ? JSON.parse(row.example_parameters) : null,
propertiesSchema: row.properties_schema ? JSON.parse(row.properties_schema) : null,
packageName: row.package_name,
version: row.version,
codexData: row.codex_data ? JSON.parse(row.codex_data) : null,
aliases: row.aliases ? JSON.parse(row.aliases) : null,
hasCredentials: row.has_credentials === 1,
isTrigger: row.is_trigger === 1,
isWebhook: row.is_webhook === 1
};
}
/**
* Generate hash for content
*/
private generateHash(content: string): string {
return createHash('sha256').update(content).digest('hex');
}
/**
* Store extraction statistics
*/
private storeStatistics(stats: any): void {
if (!this.db) throw new Error('Database not initialized');
const stmt = this.db.prepare(`
INSERT INTO extraction_stats (
total_nodes, nodes_with_docs, nodes_with_examples,
total_code_size, total_docs_size
) VALUES (?, ?, ?, ?, ?)
`);
// Calculate sizes
const sizeStats = this.db!.prepare(`
SELECT
COUNT(*) as total,
SUM(CASE WHEN documentation_markdown IS NOT NULL THEN 1 ELSE 0 END) as with_docs,
SUM(CASE WHEN example_workflow IS NOT NULL THEN 1 ELSE 0 END) as with_examples,
SUM(code_length) as code_size,
SUM(LENGTH(documentation_markdown)) as docs_size
FROM nodes
`).get() as any;
stmt.run(
stats.successful,
sizeStats?.with_docs || 0,
sizeStats?.with_examples || 0,
sizeStats?.code_size || 0,
sizeStats?.docs_size || 0
);
}
/**
* Get database statistics
*/
async getStatistics(): Promise<any> {
await this.ensureInitialized();
const stats = this.db!.prepare(`
SELECT
COUNT(*) as totalNodes,
COUNT(DISTINCT package_name) as totalPackages,
SUM(code_length) as totalCodeSize,
SUM(CASE WHEN documentation_markdown IS NOT NULL THEN 1 ELSE 0 END) as nodesWithDocs,
SUM(CASE WHEN example_workflow IS NOT NULL THEN 1 ELSE 0 END) as nodesWithExamples,
SUM(has_credentials) as nodesWithCredentials,
SUM(is_trigger) as triggerNodes,
SUM(is_webhook) as webhookNodes
FROM nodes
`).get() as any;
const packages = this.db!.prepare(`
SELECT package_name as package, COUNT(*) as count
FROM nodes
GROUP BY package_name
ORDER BY count DESC
`).all();
return {
totalNodes: stats?.totalNodes || 0,
totalPackages: stats?.totalPackages || 0,
totalCodeSize: stats?.totalCodeSize || 0,
nodesWithDocs: stats?.nodesWithDocs || 0,
nodesWithExamples: stats?.nodesWithExamples || 0,
nodesWithCredentials: stats?.nodesWithCredentials || 0,
triggerNodes: stats?.triggerNodes || 0,
webhookNodes: stats?.webhookNodes || 0,
packageDistribution: packages
};
}
/**
* Close database connection
*/
async close(): Promise<void> {
await this.ensureInitialized();
this.db!.close();
}
}