Compare commits

..

1 Commits

Author SHA1 Message Date
czlonkowski
863ab96405 chore: update n8n dependencies to v1.108.1
- n8n: 1.106.3 → 1.108.1
- n8n-core: 1.105.3 → 1.107.1
- n8n-workflow: 1.103.3 → 1.105.0
- @n8n/n8n-nodes-langchain: 1.105.3 → 1.107.0
- Rebuilt nodes database with 535 nodes
- Bumped version to 2.10.7
2025-08-26 08:36:32 +02:00
50 changed files with 18246 additions and 32574 deletions

View File

@@ -86,24 +86,4 @@ AUTH_TOKEN=your-secure-token-here
# N8N_API_TIMEOUT=30000
# Maximum number of API request retries (default: 3)
# N8N_API_MAX_RETRIES=3
# =========================
# OPENAI API CONFIGURATION
# =========================
# Optional: Enable AI-powered template metadata generation
# Provides structured metadata for improved template discovery
# OpenAI API Key (get from https://platform.openai.com/api-keys)
# OPENAI_API_KEY=
# OpenAI Model for metadata generation (default: gpt-4o-mini)
# OPENAI_MODEL=gpt-4o-mini
# Batch size for metadata generation (default: 100)
# Templates are processed in batches using OpenAI's Batch API for 50% cost savings
# OPENAI_BATCH_SIZE=100
# Enable metadata generation during template fetch (default: false)
# Set to true to automatically generate metadata when running fetch:templates
# METADATA_GENERATION_ENABLED=false
# N8N_API_MAX_RETRIES=3

4
.gitignore vendored
View File

@@ -94,7 +94,6 @@ tmp/
# data/*.db
data/*.db-journal
data/*.db.bak
data/*.db.backup
!data/.gitkeep
!data/nodes.db
@@ -127,6 +126,3 @@ n8n-mcp-wrapper.sh
# Package tarballs
*.tgz
# MCP configuration files
.mcp.json

View File

@@ -191,5 +191,4 @@ NEVER proactively create documentation files (*.md) or README files. Only create
- When you make changes to MCP server, you need to ask the user to reload it before you test
- When the user asks to review issues, you should use GH CLI to get the issue and all the comments
- When the task can be divided into separated subtasks, you should spawn separate sub-agents to handle them in paralel
- Use the best sub-agent for the task as per their descriptions
- Do not use hyperbolic or dramatic language in comments and documentation
- Use the best sub-agent for the task as per their descriptions

View File

@@ -9,13 +9,11 @@ WORKDIR /app
COPY tsconfig*.json ./
# Create minimal package.json and install ONLY build dependencies
# Note: openai and zod are needed for TypeScript compilation of template metadata modules
RUN --mount=type=cache,target=/root/.npm \
echo '{}' > package.json && \
npm install --no-save typescript@^5.8.3 @types/node@^22.15.30 @types/express@^5.0.3 \
@modelcontextprotocol/sdk@^1.12.1 dotenv@^16.5.0 express@^5.1.0 axios@^1.10.0 \
n8n-workflow@^1.96.0 uuid@^11.0.5 @types/uuid@^10.0.0 \
openai@^4.77.0 zod@^3.24.1
n8n-workflow@^1.96.0 uuid@^11.0.5 @types/uuid@^10.0.0
# Copy source and build
COPY src ./src

115
README.md
View File

@@ -2,11 +2,11 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![GitHub stars](https://img.shields.io/github/stars/czlonkowski/n8n-mcp?style=social)](https://github.com/czlonkowski/n8n-mcp)
[![Version](https://img.shields.io/badge/version-2.11.1-blue.svg)](https://github.com/czlonkowski/n8n-mcp)
[![Version](https://img.shields.io/badge/version-2.10.7-blue.svg)](https://github.com/czlonkowski/n8n-mcp)
[![npm version](https://img.shields.io/npm/v/n8n-mcp.svg)](https://www.npmjs.com/package/n8n-mcp)
[![codecov](https://codecov.io/gh/czlonkowski/n8n-mcp/graph/badge.svg?token=YOUR_TOKEN)](https://codecov.io/gh/czlonkowski/n8n-mcp)
[![Tests](https://img.shields.io/badge/tests-1728%20passing-brightgreen.svg)](https://github.com/czlonkowski/n8n-mcp/actions)
[![n8n version](https://img.shields.io/badge/n8n-^1.110.1-orange.svg)](https://github.com/n8n-io/n8n)
[![n8n version](https://img.shields.io/badge/n8n-^1.108.1-orange.svg)](https://github.com/n8n-io/n8n)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fczlonkowski%2Fn8n--mcp-green.svg)](https://github.com/czlonkowski/n8n-mcp/pkgs/container/n8n-mcp)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/n8n-mcp?referralCode=n8n-mcp)
@@ -357,55 +357,38 @@ You are an expert in n8n automation software using n8n-MCP tools. Your role is t
1. **ALWAYS start new conversation with**: `tools_documentation()` to understand best practices and available tools.
2. **Template Discovery Phase**
- `search_templates_by_metadata({complexity: "simple"})` - Find skill-appropriate templates
- `get_templates_for_task('webhook_processing')` - Get curated templates by task
- `search_templates('slack notification')` - Text search for specific needs
- `list_node_templates(['n8n-nodes-base.slack'])` - Find templates using specific nodes
**Template filtering strategies**:
- **For beginners**: `complexity: "simple"` and `maxSetupMinutes: 30`
- **By role**: `targetAudience: "marketers"` or `"developers"` or `"analysts"`
- **By time**: `maxSetupMinutes: 15` for quick wins
- **By service**: `requiredService: "openai"` to find compatible templates
3. **Discovery Phase** - Find the right nodes (if no suitable template):
2. **Discovery Phase** - Find the right nodes:
- Think deeply about user request and the logic you are going to build to fulfill it. Ask follow-up questions to clarify the user's intent, if something is unclear. Then, proceed with the rest of your instructions.
- `search_nodes({query: 'keyword'})` - Search by functionality
- `list_nodes({category: 'trigger'})` - Browse by category
- `list_ai_tools()` - See AI-capable nodes (remember: ANY node can be an AI tool!)
4. **Configuration Phase** - Get node details efficiently:
3. **Configuration Phase** - Get node details efficiently:
- `get_node_essentials(nodeType)` - Start here! Only 10-20 essential properties
- `search_node_properties(nodeType, 'auth')` - Find specific properties
- `get_node_for_task('send_email')` - Get pre-configured templates
- `get_node_documentation(nodeType)` - Human-readable docs when needed
- It is good common practice to show a visual representation of the workflow architecture to the user and asking for opinion, before moving forward.
5. **Pre-Validation Phase** - Validate BEFORE building:
4. **Pre-Validation Phase** - Validate BEFORE building:
- `validate_node_minimal(nodeType, config)` - Quick required fields check
- `validate_node_operation(nodeType, config, profile)` - Full operation-aware validation
- Fix any validation errors before proceeding
6. **Building Phase** - Create or customize the workflow:
- If using template: `get_template(templateId, {mode: "full"})`
- **MANDATORY ATTRIBUTION**: When using a template, ALWAYS inform the user:
- "This workflow is based on a template by **[author.name]** (@[author.username])"
- "View the original template at: [url]"
- Example: "This workflow is based on a template by **David Ashby** (@cfomodz). View the original at: https://n8n.io/workflows/2414"
- Customize template or build from validated configurations
5. **Building Phase** - Create the workflow:
- Use validated configurations from step 4
- Connect nodes with proper structure
- Add error handling where appropriate
- Use expressions like $json, $node["NodeName"].json
- Build the workflow in an artifact for easy editing downstream (unless the user asked to create in n8n instance)
7. **Workflow Validation Phase** - Validate complete workflow:
6. **Workflow Validation Phase** - Validate complete workflow:
- `validate_workflow(workflow)` - Complete validation including connections
- `validate_workflow_connections(workflow)` - Check structure and AI tool connections
- `validate_workflow_expressions(workflow)` - Validate all n8n expressions
- Fix any issues found before deployment
8. **Deployment Phase** (if n8n API configured):
7. **Deployment Phase** (if n8n API configured):
- `n8n_create_workflow(workflow)` - Deploy validated workflow
- `n8n_validate_workflow({id: 'workflow-id'})` - Post-deployment validation
- `n8n_update_partial_workflow()` - Make incremental updates using diffs
@@ -413,9 +396,6 @@ You are an expert in n8n automation software using n8n-MCP tools. Your role is t
## Key Insights
- **TEMPLATES FIRST** - Always check for existing templates before building from scratch (2,500+ available!)
- **ATTRIBUTION REQUIRED** - Always credit template authors with name, username, and link to n8n.io
- **SMART FILTERING** - Use metadata filters to find templates matching user skill level and time constraints
- **USE CODE NODE ONLY WHEN IT IS NECESSARY** - always prefer to use standard nodes over code node. Use code node only when you are sure you need it.
- **VALIDATE EARLY AND OFTEN** - Catch errors before they reach deployment
- **USE DIFF UPDATES** - Use n8n_update_partial_workflow for 80-90% token savings
@@ -454,50 +434,27 @@ You are an expert in n8n automation software using n8n-MCP tools. Your role is t
## Example Workflow
### Smart Template-First Approach
#### 1. Find existing templates
// Find simple Slack templates for marketers
const templates = search_templates_by_metadata({
requiredService: 'slack',
complexity: 'simple',
targetAudience: 'marketers',
maxSetupMinutes: 30
})
// Or search by text
search_templates('slack notification')
// Or get curated templates
get_templates_for_task('slack_integration')
#### 2. Use and customize template
const workflow = get_template(templates.items[0].id, {mode: 'full'})
validate_workflow(workflow)
### Building from Scratch (if no suitable template)
#### 1. Discovery & Configuration
### 1. Discovery & Configuration
search_nodes({query: 'slack'})
get_node_essentials('n8n-nodes-base.slack')
#### 2. Pre-Validation
### 2. Pre-Validation
validate_node_minimal('n8n-nodes-base.slack', {resource:'message', operation:'send'})
validate_node_operation('n8n-nodes-base.slack', fullConfig, 'runtime')
#### 3. Build Workflow
### 3. Build Workflow
// Create workflow JSON with validated configs
#### 4. Workflow Validation
### 4. Workflow Validation
validate_workflow(workflowJson)
validate_workflow_connections(workflowJson)
validate_workflow_expressions(workflowJson)
#### 5. Deploy (if configured)
### 5. Deploy (if configured)
n8n_create_workflow(validatedWorkflow)
n8n_validate_workflow({id: createdWorkflowId})
#### 6. Update Using Diffs
### 6. Update Using Diffs
n8n_update_partial_workflow({
workflowId: id,
operations: [
@@ -507,24 +464,15 @@ n8n_update_partial_workflow({
## Important Rules
- ALWAYS check for existing templates before building from scratch
- LEVERAGE metadata filters to find skill-appropriate templates
- **ALWAYS ATTRIBUTE TEMPLATES**: When using any template, you MUST share the author's name, username, and link to the original template on n8n.io
- VALIDATE templates before deployment (they may need updates)
- ALWAYS validate before building
- ALWAYS validate after building
- NEVER deploy unvalidated workflows
- USE diff operations for updates (80-90% token savings)
- STATE validation results clearly
- FIX all errors before proceeding
## Template Discovery Tips
- **97.5% of templates have metadata** - Use smart filtering!
- **Filter combinations work best** - Combine complexity + setup time + service
- **Templates save 70-90% development time** - Always check first
- **Metadata is AI-generated** - Occasionally imprecise but highly useful
- **Use `includeMetadata: false` for fast browsing** - Add metadata only when needed
```
Save these instructions in your Claude Project for optimal n8n workflow assistance with intelligent template discovery.
Save these instructions in your Claude Project for optimal n8n workflow assistance with comprehensive validation.
## 🚨 Important: Sharing Guidelines
@@ -576,14 +524,6 @@ Once connected, Claude can use these powerful tools:
- **`list_ai_tools`** - List all AI-capable nodes (ANY node can be used as AI tool!)
- **`get_node_as_tool_info`** - Get guidance on using any node as an AI tool
### Template Tools
- **`list_templates`** - Browse all templates with descriptions and optional metadata (2,500+ templates)
- **`search_templates`** - Text search across template names and descriptions
- **`search_templates_by_metadata`** - Advanced filtering by complexity, setup time, services, audience
- **`list_node_templates`** - Find templates using specific nodes
- **`get_template`** - Get complete workflow JSON for import
- **`get_templates_for_task`** - Curated templates for common automation tasks
### Advanced Tools
- **`get_node_for_task`** - Pre-configured node settings for common tasks
- **`list_tasks`** - Discover available task templates
@@ -867,23 +807,6 @@ See [Automated Release Guide](./docs/AUTOMATED_RELEASES.md) for complete details
- [Anthropic](https://anthropic.com) for the Model Context Protocol
- All contributors and users of this project
### Template Attribution
All workflow templates in this project are fetched from n8n's public template gallery at [n8n.io/workflows](https://n8n.io/workflows). Each template includes:
- Full attribution to the original creator (name and username)
- Direct link to the source template on n8n.io
- Original workflow ID for reference
The AI agent instructions in this project contain mandatory attribution requirements. When using any template, the AI will automatically:
- Share the template author's name and username
- Provide a direct link to the original template on n8n.io
- Display attribution in the format: "This workflow is based on a template by **[author]** (@[username]). View the original at: [url]"
Template creators retain all rights to their workflows. This project indexes templates to improve discoverability through AI assistants. If you're a template creator and have concerns about your template being indexed, please open an issue.
Special thanks to the prolific template contributors whose work helps thousands of users automate their workflows, including:
**David Ashby** (@cfomodz), **Yaron Been** (@yaron-nofluff), **Jimleuk** (@jimleuk), **Davide** (@n3witalia), **David Olusola** (@dae221), **Ranjan Dailata** (@ranjancse), **Airtop** (@cesar-at-airtop), **Joseph LePage** (@joe), **Don Jayamaha Jr** (@don-the-gem-dealer), **Angel Menendez** (@djangelic), and the entire n8n community of creators!
---
<div align="center">

Binary file not shown.

View File

@@ -7,147 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.11.1] - 2025-09-15
### Added
- **Optional Fields Parameter for search_templates**: Enhanced search_templates tool with field filtering capability
- New optional `fields` parameter accepts an array of field names to include in response
- Supported fields: 'id', 'name', 'description', 'author', 'nodes', 'views', 'created', 'url', 'metadata'
- Reduces response size by 70-98% when requesting only specific fields (e.g., just id and name)
- Maintains full backward compatibility - existing calls without fields parameter work unchanged
- Example: `search_templates({query: "slack", fields: ["id", "name"]})` returns minimal data
- Significantly improves AI agent performance by reducing token usage
### Added
- **Fuzzy Node Type Matching for Templates**: Improved template discovery with flexible node type resolution
- Templates can now be found using simple node names: `["slack"]` instead of `["n8n-nodes-base.slack"]`
- Accepts various input formats: bare names, partial prefixes, and case variations
- Automatically expands related node types: `["email"]` finds Gmail, email send, and related templates
- `["slack"]` also finds `slackTrigger` templates
- Case-insensitive matching: `["Slack"]`, `["WEBHOOK"]`, `["HttpRequest"]` all work
- Backward compatible - existing exact formats continue working
- Reduces failed queries by approximately 50%
- Added `template-node-resolver.ts` utility for node type resolution
- Added 23 tests for template node resolution
- **Structured Template Metadata System**: Comprehensive metadata for intelligent template discovery
- Generated metadata for 2,534 templates (97.5% coverage) using OpenAI's batch API
- Rich metadata structure: categories, complexity, use cases, setup time, required services, key features, target audience
- New `search_templates_by_metadata` tool for advanced filtering by multiple criteria
- Enhanced `list_templates` tool with optional `includeMetadata` parameter
- Templates now always include descriptions in list responses
- Metadata enables filtering by complexity level (simple/medium/complex)
- Filter by estimated setup time ranges (5-480 minutes)
- Filter by required external services (OpenAI, Slack, Google, etc.)
- Filter by target audience (developers, marketers, analysts, etc.)
- Multiple filter combinations supported for precise template discovery
- SQLite JSON extraction for efficient metadata queries
- Batch processing with OpenAI's gpt-4o-mini model for cost efficiency
- Added comprehensive tool documentation for new metadata features
- New database columns: metadata_json, metadata_generated_at
- Repository methods for metadata search and filtering
## [2.11.0] - 2025-01-14
### Added
- **Comprehensive Template Pagination**: All template search and list tools now return paginated responses
- Consistent `PaginatedResponse` format with `items`, `total`, `limit`, `offset`, and `hasMore` fields
- Customizable limits (1-100) and offset parameters for all template tools
- Count methods for accurate pagination information across all template queries
- **New `list_templates` Tool**: Efficient browsing of all available templates
- Returns minimal data (id, name, views, nodeCount) for quick overview
- Supports sorting by views, created_at, or name
- Optimized for discovering templates without downloading full workflow data
- **Flexible Template Retrieval Modes**: Enhanced `get_template` with three response modes
- `nodes_only`: Returns just node types and names (minimal tokens)
- `structure`: Returns nodes with positions and connections (moderate detail)
- `full`: Returns complete workflow JSON (default, maximum detail)
- Reduces token usage by 80-90% in minimal modes
### Enhanced
- **Template Database Compression**: Implemented gzip compression for workflow JSONs
- Workflow data compressed from ~75MB to 12.10MB (84% reduction)
- Database size reduced from 117MB to 48MB despite 5x more templates
- Transparent compression/decompression with base64 encoding
- No API changes - compression is handled internally
- **Template Quality Filtering**: Automatic filtering of low-quality templates
- Templates with ≤10 views are excluded from the database
- Expanded coverage from 499 to 2,596 high-quality templates (5x increase)
- Filtered 4,505 raw templates down to 2,596 based on popularity
- Ensures AI agents work with proven, valuable workflows
- **Enhanced Database Statistics**: Template metrics now included
- Shows total template count, average/min/max views
- Provides complete database overview including template coverage
### Performance
- **Database Optimization**: 59% size reduction while storing 5x more content
- Previous: ~40MB database with 499 templates
- Current: ~48MB database with 2,596 templates
- Without compression would be ~120MB+
- **Token Efficiency**: 80-90% reduction in response size for minimal queries
- `list_templates`: ~10 tokens per template vs 100+ for full data
- `get_template` with `nodes_only`: Returns just essential node information
- Pagination prevents overwhelming responses for large result sets
### Fixed
- **Test Suite Compatibility**: Updated all tests for new template system
- Fixed parameter validation tests to expect new method signatures
- Updated integration tests to use templates with >10 views
- Removed redundant test files that were testing at wrong abstraction level
- All 1,700+ tests now passing
## [2.10.9] - 2025-01-09
### Changed
- **Dependencies**: Updated n8n packages to 1.110.1
- n8n: 1.109.2 → 1.110.1
- n8n-core: 1.108.0 → 1.109.0
- n8n-workflow: 1.106.0 → 1.107.0
- @n8n/n8n-nodes-langchain: 1.108.1 → 1.109.1
## [2.10.7] - 2025-08-26
### Updated
- **Node Database**: Rebuilt with 536 nodes from updated n8n packages
- **Templates**: Refreshed workflow templates database with latest 499 templates from n8n.io
## [2.10.8] - 2025-09-04
### Updated
- **n8n Dependencies**: Updated to latest versions for compatibility and new features
- n8n: 1.107.4 → 1.109.2
- @n8n/n8n-nodes-langchain: 1.106.2 → 1.109.1
- n8n-nodes-base: 1.106.3 → 1.108.0 (via dependencies)
- **Node Database**: Rebuilt with 535 nodes from updated n8n packages
- **Node.js Compatibility**: Optimized for Node.js v22.17.0 LTS
- Enhanced better-sqlite3 native binary compatibility
- Fixed SQL.js fallback mode for environments without native binaries
- **CI/CD Improvements**: Fixed Rollup native module compatibility for GitHub Actions
- Added explicit platform-specific rollup binaries for cross-platform builds
- Resolved npm ci failures in Linux CI environment
- Fixed package-lock.json synchronization issues
- **Platform Support**: Enhanced cross-platform deployment compatibility
- macOS ARM64 and Linux x64 platform binaries included
- Improved npm package distribution with proper dependency resolution
- All 1,728+ tests passing with updated dependencies
### Fixed
- **CI/CD Pipeline**: Resolved test failures in GitHub Actions
- Fixed pyodide version conflicts between langchain dependencies
- Regenerated package-lock.json with proper dependency resolution
- Fixed Rollup native module loading in Linux CI environment
- **Database Compatibility**: Enhanced SQL.js fallback reliability
- Improved parameter binding and state management
- Fixed statement cleanup to prevent memory leaks
- **Deployment Reliability**: Better handling of platform-specific dependencies
- npm ci now works consistently across development and CI environments
## [2.10.5] - 2025-08-20
### Updated
- **n8n Dependencies**: Updated to latest versions for compatibility and new features
- n8n: 1.106.3 → 1.107.4
- n8n-core: 1.105.3 → 1.106.2
- n8n-workflow: 1.103.3 → 1.104.1
- @n8n/n8n-nodes-langchain: 1.105.3 → 1.106.2
- **Node Database**: Rebuilt with 535 nodes from updated n8n packages
- **n8n Dependencies**: Updated to latest stable versions
- n8n: 1.106.3 → 1.108.1
- n8n-core: 1.105.3 → 1.107.1
- n8n-workflow: 1.103.3 → 1.105.0
- @n8n/n8n-nodes-langchain: 1.105.3 → 1.107.0
- **Node Database**: Rebuilt with 535 nodes from n8n v1.108.1
- All tests passing with updated dependencies
## [2.10.4] - 2025-08-12
@@ -1297,11 +1165,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Basic n8n and MCP integration
- Core workflow automation features
[2.11.1]: https://github.com/czlonkowski/n8n-mcp/compare/v2.11.0...v2.11.1
[2.11.0]: https://github.com/czlonkowski/n8n-mcp/compare/v2.10.9...v2.11.0
[2.10.9]: https://github.com/czlonkowski/n8n-mcp/compare/v2.10.8...v2.10.9
[2.10.8]: https://github.com/czlonkowski/n8n-mcp/compare/v2.10.5...v2.10.8
[2.10.5]: https://github.com/czlonkowski/n8n-mcp/compare/v2.10.4...v2.10.5
[2.10.4]: https://github.com/czlonkowski/n8n-mcp/compare/v2.10.3...v2.10.4
[2.10.3]: https://github.com/czlonkowski/n8n-mcp/compare/v2.10.2...v2.10.3
[2.10.2]: https://github.com/czlonkowski/n8n-mcp/compare/v2.10.1...v2.10.2

View File

@@ -1,314 +0,0 @@
# Template Metadata Generation
This document describes the template metadata generation system introduced in n8n-MCP v2.10.0, which uses OpenAI's batch API to automatically analyze and categorize workflow templates.
## Overview
The template metadata system analyzes n8n workflow templates to extract structured information about their purpose, complexity, requirements, and target audience. This enables intelligent template discovery through advanced filtering capabilities.
## Architecture
### Components
1. **MetadataGenerator** (`src/templates/metadata-generator.ts`)
- Interfaces with OpenAI API
- Generates structured metadata using JSON schemas
- Provides fallback defaults for error cases
2. **BatchProcessor** (`src/templates/batch-processor.ts`)
- Manages OpenAI batch API operations
- Handles parallel batch submission
- Monitors batch status and retrieves results
3. **Template Repository** (`src/templates/template-repository.ts`)
- Stores metadata in SQLite database
- Provides advanced search capabilities
- Supports JSON extraction queries
## Metadata Schema
Each template's metadata contains:
```typescript
{
categories: string[] // Max 5 categories (e.g., "automation", "integration")
complexity: "simple" | "medium" | "complex"
use_cases: string[] // Max 5 primary use cases
estimated_setup_minutes: number // 5-480 minutes
required_services: string[] // External services needed
key_features: string[] // Max 5 main capabilities
target_audience: string[] // Max 3 target user types
}
```
## Generation Process
### 1. Initial Setup
```bash
# Set OpenAI API key in .env
OPENAI_API_KEY=your-api-key-here
```
### 2. Generate Metadata for Existing Templates
```bash
# Generate metadata only (no template fetching)
npm run fetch:templates -- --metadata-only
# Generate metadata during update
npm run fetch:templates -- --mode=update --generate-metadata
```
### 3. Batch Processing
The system uses OpenAI's batch API for cost-effective processing:
- **50% cost reduction** compared to synchronous API calls
- **24-hour processing window** for batch completion
- **Parallel batch submission** for faster processing
- **Automatic retry** for failed items
### Configuration Options
Environment variables:
- `OPENAI_API_KEY`: Required for metadata generation
- `OPENAI_MODEL`: Model to use (default: "gpt-4o-mini")
- `OPENAI_BATCH_SIZE`: Templates per batch (default: 100, max: 500)
- `METADATA_LIMIT`: Limit templates to process (for testing)
## How It Works
### 1. Template Analysis
For each template, the generator analyzes:
- Template name and description
- Node types and their frequency
- Workflow structure and connections
- Overall complexity
### 2. Node Summarization
Nodes are grouped into categories:
- HTTP/Webhooks
- Database operations
- Communication (Slack, Email)
- AI/ML operations
- Spreadsheets
- Service-specific nodes
### 3. Metadata Generation
The AI model receives:
```
Template: [name]
Description: [description]
Nodes Used (X): [summarized node list]
Workflow has X nodes with Y connections
```
And generates structured metadata following the JSON schema.
### 4. Storage and Indexing
Metadata is stored as JSON in SQLite and indexed for fast querying:
```sql
-- Example query for simple automation templates
SELECT * FROM templates
WHERE json_extract(metadata, '$.complexity') = 'simple'
AND json_extract(metadata, '$.categories') LIKE '%automation%'
```
## MCP Tool Integration
### search_templates_by_metadata
Advanced filtering tool with multiple parameters:
```typescript
search_templates_by_metadata({
category: "automation", // Filter by category
complexity: "simple", // Skill level
maxSetupMinutes: 30, // Time constraint
targetAudience: "marketers", // Role-based
requiredService: "slack" // Service dependency
})
```
### list_templates
Enhanced to include metadata:
```typescript
list_templates({
includeMetadata: true, // Include full metadata
limit: 20,
offset: 0
})
```
## Usage Examples
### Finding Beginner-Friendly Templates
```typescript
const templates = await search_templates_by_metadata({
complexity: "simple",
maxSetupMinutes: 15
});
```
### Role-Specific Templates
```typescript
const marketingTemplates = await search_templates_by_metadata({
targetAudience: "marketers",
category: "communication"
});
```
### Service Integration Templates
```typescript
const openaiTemplates = await search_templates_by_metadata({
requiredService: "openai",
complexity: "medium"
});
```
## Performance Metrics
- **Coverage**: 97.5% of templates have metadata (2,534/2,598)
- **Generation Time**: ~2-4 hours for full database (using batch API)
- **Query Performance**: <100ms for metadata searches
- **Storage Overhead**: ~2MB additional database size
## Troubleshooting
### Common Issues
1. **Batch Processing Stuck**
- Check batch status: The API provides status updates
- Batches auto-expire after 24 hours
- Monitor using the batch ID in logs
2. **Missing Metadata**
- ~2.5% of templates may fail metadata generation
- Fallback defaults are provided
- Can regenerate with `--metadata-only` flag
3. **API Rate Limits**
- Batch API has generous limits (50,000 requests/batch)
- Cost is 50% of synchronous API
- Processing happens within 24-hour window
### Monitoring Batch Status
```bash
# Check current batch status (if logged)
curl https://api.openai.com/v1/batches/[batch-id] \
-H "Authorization: Bearer $OPENAI_API_KEY"
```
## Cost Analysis
### Batch API Pricing (gpt-4o-mini)
- Input: $0.075 per 1M tokens (50% of standard)
- Output: $0.30 per 1M tokens (50% of standard)
- Average template: ~300 input tokens, ~200 output tokens
- Total cost for 2,500 templates: ~$0.50
### Comparison with Synchronous API
- Synchronous cost: ~$1.00 for same volume
- Time saved: Parallel processing vs sequential
- Reliability: Automatic retries included
## Future Enhancements
### Planned Improvements
1. **Incremental Updates**
- Only generate metadata for new templates
- Track metadata version for updates
2. **Enhanced Analysis**
- Workflow complexity scoring
- Dependency graph analysis
- Performance impact estimates
3. **User Feedback Loop**
- Collect accuracy feedback
- Refine categorization over time
- Community-driven corrections
4. **Alternative Models**
- Support for local LLMs
- Claude API integration
- Configurable model selection
## Implementation Details
### Database Schema
```sql
-- Metadata stored as JSON column
ALTER TABLE templates ADD COLUMN metadata TEXT;
-- Indexes for common queries
CREATE INDEX idx_templates_complexity ON templates(
json_extract(metadata, '$.complexity')
);
CREATE INDEX idx_templates_setup_time ON templates(
json_extract(metadata, '$.estimated_setup_minutes')
);
```
### Error Handling
The system provides robust error handling:
1. **API Failures**: Fallback to default metadata
2. **Parsing Errors**: Logged with template ID
3. **Batch Failures**: Individual item retry
4. **Validation Errors**: Zod schema enforcement
## Maintenance
### Regenerating Metadata
```bash
# Full regeneration (caution: costs ~$0.50)
npm run fetch:templates -- --mode=rebuild --generate-metadata
# Partial regeneration (templates without metadata)
npm run fetch:templates -- --metadata-only
```
### Database Backup
```bash
# Backup before regeneration
cp data/nodes.db data/nodes.db.backup
# Restore if needed
cp data/nodes.db.backup data/nodes.db
```
## Security Considerations
1. **API Key Management**
- Store in `.env` file (gitignored)
- Never commit API keys
- Use environment variables in CI/CD
2. **Data Privacy**
- Only template structure is sent to API
- No user data or credentials included
- Processing happens in OpenAI's secure environment
## Conclusion
The template metadata system transforms template discovery from simple text search to intelligent, multi-dimensional filtering. By leveraging OpenAI's batch API, we achieve cost-effective, scalable metadata generation that significantly improves the user experience for finding relevant workflow templates.

File diff suppressed because one or more lines are too long

View File

@@ -1,32 +0,0 @@
#!/bin/bash
echo "Monitoring template fetch progress..."
echo "=================================="
while true; do
# Check if process is still running
if ! pgrep -f "fetch-templates" > /dev/null; then
echo "Fetch process completed!"
break
fi
# Get database size
DB_SIZE=$(ls -lh data/nodes.db 2>/dev/null | awk '{print $5}')
# Get template count
TEMPLATE_COUNT=$(sqlite3 data/nodes.db "SELECT COUNT(*) FROM templates" 2>/dev/null || echo "0")
# Get last log entry
LAST_LOG=$(tail -n 1 fetch_log.txt 2>/dev/null | grep "Fetching template details" | tail -1)
# Display status
echo -ne "\rDB Size: $DB_SIZE | Templates: $TEMPLATE_COUNT | $LAST_LOG"
sleep 5
done
echo ""
echo "Final statistics:"
echo "-----------------"
ls -lh data/nodes.db
sqlite3 data/nodes.db "SELECT COUNT(*) as count, printf('%.1f MB', SUM(LENGTH(workflow_json_compressed))/1024.0/1024.0) as compressed_size FROM templates"

BIN
nodes.db Normal file

Binary file not shown.

28291
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "n8n-mcp",
"version": "2.11.1",
"version": "2.10.7",
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
"main": "dist/index.js",
"bin": {
@@ -128,20 +128,16 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.13.2",
"@n8n/n8n-nodes-langchain": "^1.109.1",
"@n8n/n8n-nodes-langchain": "^1.107.0",
"dotenv": "^16.5.0",
"express": "^5.1.0",
"n8n": "^1.110.1",
"n8n-core": "^1.109.0",
"n8n-workflow": "^1.107.0",
"openai": "^4.77.0",
"n8n": "^1.108.1",
"n8n-core": "^1.107.1",
"n8n-workflow": "^1.105.0",
"sql.js": "^1.13.0",
"uuid": "^10.0.0",
"zod": "^3.24.1"
"uuid": "^10.0.0"
},
"optionalDependencies": {
"@rollup/rollup-darwin-arm64": "^4.50.0",
"@rollup/rollup-linux-x64-gnu": "^4.50.0",
"better-sqlite3": "^11.10.0"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "n8n-mcp-runtime",
"version": "2.10.9",
"version": "2.10.7",
"description": "n8n MCP Server Runtime Dependencies Only",
"private": true,
"dependencies": {

View File

@@ -1,62 +0,0 @@
#!/bin/bash
# Quick publish script that skips tests
set -e
# Color codes
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo "🚀 Preparing n8n-mcp for npm publish (quick mode)..."
# Sync version
echo "🔄 Syncing version to package.runtime.json..."
npm run sync:runtime-version
VERSION=$(node -e "console.log(require('./package.json').version)")
echo -e "${GREEN}📌 Version: $VERSION${NC}"
# Prepare publish directory
PUBLISH_DIR="npm-publish-temp"
rm -rf $PUBLISH_DIR
mkdir -p $PUBLISH_DIR
echo "📦 Copying files..."
cp -r dist $PUBLISH_DIR/
cp -r data $PUBLISH_DIR/
cp README.md LICENSE .env.example $PUBLISH_DIR/
cp .npmignore $PUBLISH_DIR/ 2>/dev/null || true
cp package.runtime.json $PUBLISH_DIR/package.json
cd $PUBLISH_DIR
# Configure package.json
node -e "
const pkg = require('./package.json');
pkg.name = 'n8n-mcp';
pkg.description = 'Integration between n8n workflow automation and Model Context Protocol (MCP)';
pkg.bin = { 'n8n-mcp': './dist/mcp/index.js' };
pkg.repository = { type: 'git', url: 'git+https://github.com/czlonkowski/n8n-mcp.git' };
pkg.keywords = ['n8n', 'mcp', 'model-context-protocol', 'ai', 'workflow', 'automation'];
pkg.author = 'Romuald Czlonkowski @ www.aiadvisors.pl/en';
pkg.license = 'MIT';
pkg.bugs = { url: 'https://github.com/czlonkowski/n8n-mcp/issues' };
pkg.homepage = 'https://github.com/czlonkowski/n8n-mcp#readme';
pkg.files = ['dist/**/*', 'data/nodes.db', '.env.example', 'README.md', 'LICENSE'];
delete pkg.private;
require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2));
"
echo ""
echo "📋 Package details:"
echo -e "${GREEN}Name:${NC} $(node -e "console.log(require('./package.json').name)")"
echo -e "${GREEN}Version:${NC} $(node -e "console.log(require('./package.json').version)")"
echo -e "${GREEN}Size:${NC} ~50MB"
echo ""
echo "✅ Ready to publish!"
echo ""
echo -e "${YELLOW}⚠️ Note: Tests were skipped in quick mode${NC}"
echo ""
echo "To publish, run:"
echo -e " ${GREEN}cd $PUBLISH_DIR${NC}"
echo -e " ${GREEN}npm publish --otp=YOUR_OTP_CODE${NC}"

View File

@@ -13,27 +13,12 @@ echo "🚀 Preparing n8n-mcp for npm publish..."
# Run tests first to ensure quality
echo "🧪 Running tests..."
TEST_OUTPUT=$(npm test 2>&1)
TEST_EXIT_CODE=$?
# Check test results - look for actual test failures vs coverage issues
if echo "$TEST_OUTPUT" | grep -q "Tests.*failed"; then
# Extract failed count using sed (portable)
FAILED_COUNT=$(echo "$TEST_OUTPUT" | sed -n 's/.*Tests.*\([0-9]*\) failed.*/\1/p' | head -1)
if [ "$FAILED_COUNT" != "0" ] && [ "$FAILED_COUNT" != "" ]; then
echo -e "${RED}$FAILED_COUNT test(s) failed. Aborting publish.${NC}"
echo "$TEST_OUTPUT" | tail -20
exit 1
fi
fi
# If we got here, tests passed - check coverage
if echo "$TEST_OUTPUT" | grep -q "Coverage.*does not meet global threshold"; then
echo -e "${YELLOW}⚠️ All tests passed but coverage is below threshold${NC}"
echo -e "${YELLOW} Consider improving test coverage before next release${NC}"
else
echo -e "${GREEN}✅ All tests passed with good coverage!${NC}"
npm test
if [ $? -ne 0 ]; then
echo -e "${RED}❌ Tests failed. Aborting publish.${NC}"
exit 1
fi
echo -e "${GREEN}✅ All tests passed!${NC}"
# Sync version to runtime package first
echo "🔄 Syncing version to package.runtime.json..."

View File

@@ -10,7 +10,7 @@ import { getToolDocumentation } from '../src/mcp/tools-documentation';
import { ExampleGenerator } from '../src/services/example-generator';
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator';
const dbPath = process.env.NODE_DB_PATH || './data/nodes.db';
const dbPath = process.env.NODE_DB_PATH || './nodes.db';
async function main() {
console.log('🧪 Testing Code Node Documentation Fixes\n');

View File

@@ -376,71 +376,52 @@ class SQLJSStatement implements PreparedStatement {
constructor(private stmt: any, private onModify: () => void) {}
run(...params: any[]): RunResult {
try {
if (params.length > 0) {
this.bindParams(params);
if (this.boundParams) {
this.stmt.bind(this.boundParams);
}
}
this.stmt.run();
this.onModify();
// sql.js doesn't provide changes/lastInsertRowid easily
return {
changes: 1, // Assume success means 1 change
lastInsertRowid: 0
};
} catch (error) {
this.stmt.reset();
throw error;
if (params.length > 0) {
this.bindParams(params);
this.stmt.bind(this.boundParams);
}
this.stmt.run();
this.onModify();
// sql.js doesn't provide changes/lastInsertRowid easily
return {
changes: 0,
lastInsertRowid: 0
};
}
get(...params: any[]): any {
try {
if (params.length > 0) {
this.bindParams(params);
if (this.boundParams) {
this.stmt.bind(this.boundParams);
}
}
if (this.stmt.step()) {
const result = this.stmt.getAsObject();
this.stmt.reset();
return this.convertIntegerColumns(result);
}
this.stmt.reset();
return undefined;
} catch (error) {
this.stmt.reset();
throw error;
if (params.length > 0) {
this.bindParams(params);
}
this.stmt.bind(this.boundParams);
if (this.stmt.step()) {
const result = this.stmt.getAsObject();
this.stmt.reset();
return this.convertIntegerColumns(result);
}
this.stmt.reset();
return undefined;
}
all(...params: any[]): any[] {
try {
if (params.length > 0) {
this.bindParams(params);
if (this.boundParams) {
this.stmt.bind(this.boundParams);
}
}
const results: any[] = [];
while (this.stmt.step()) {
results.push(this.convertIntegerColumns(this.stmt.getAsObject()));
}
this.stmt.reset();
return results;
} catch (error) {
this.stmt.reset();
throw error;
if (params.length > 0) {
this.bindParams(params);
}
this.stmt.bind(this.boundParams);
const results: any[] = [];
while (this.stmt.step()) {
results.push(this.convertIntegerColumns(this.stmt.getAsObject()));
}
this.stmt.reset();
return results;
}
iterate(...params: any[]): IterableIterator<any> {
@@ -474,18 +455,12 @@ class SQLJSStatement implements PreparedStatement {
}
private bindParams(params: any[]): void {
if (params.length === 0) {
this.boundParams = null;
return;
}
if (params.length === 1 && typeof params[0] === 'object' && !Array.isArray(params[0]) && params[0] !== null) {
if (params.length === 1 && typeof params[0] === 'object' && !Array.isArray(params[0])) {
// Named parameters passed as object
this.boundParams = params[0];
} else {
// Positional parameters - sql.js uses array for positional
// Filter out undefined values that might cause issues
this.boundParams = params.map(p => p === undefined ? null : p);
this.boundParams = params;
}
}

View File

@@ -35,23 +35,19 @@ CREATE TABLE IF NOT EXISTS templates (
author_username TEXT,
author_verified INTEGER DEFAULT 0,
nodes_used TEXT, -- JSON array of node types
workflow_json TEXT, -- Complete workflow JSON (deprecated, use workflow_json_compressed)
workflow_json_compressed TEXT, -- Compressed workflow JSON (base64 encoded gzip)
workflow_json TEXT NOT NULL, -- Complete workflow JSON
categories TEXT, -- JSON array of categories
views INTEGER DEFAULT 0,
created_at DATETIME,
updated_at DATETIME,
url TEXT,
scraped_at DATETIME DEFAULT CURRENT_TIMESTAMP,
metadata_json TEXT, -- Structured metadata from OpenAI (JSON)
metadata_generated_at DATETIME -- When metadata was generated
scraped_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Templates indexes
CREATE INDEX IF NOT EXISTS idx_template_nodes ON templates(nodes_used);
CREATE INDEX IF NOT EXISTS idx_template_updated ON templates(updated_at);
CREATE INDEX IF NOT EXISTS idx_template_name ON templates(name);
CREATE INDEX IF NOT EXISTS idx_template_metadata ON templates(metadata_generated_at);
-- Note: FTS5 tables are created conditionally at runtime if FTS5 is supported
-- See template-repository.ts initializeFTS5() method

View File

@@ -282,7 +282,6 @@ export async function handleGetWorkflowStructure(args: unknown): Promise<McpTool
id: workflow.id,
name: workflow.name,
active: workflow.active,
isArchived: workflow.isArchived,
nodes: simplifiedNodes,
connections: workflow.connections,
nodeCount: workflow.nodes.length,
@@ -326,7 +325,6 @@ export async function handleGetWorkflowMinimal(args: unknown): Promise<McpToolRe
id: workflow.id,
name: workflow.name,
active: workflow.active,
isArchived: workflow.isArchived,
tags: workflow.tags || [],
createdAt: workflow.createdAt,
updatedAt: workflow.updatedAt
@@ -472,7 +470,6 @@ export async function handleListWorkflows(args: unknown): Promise<McpToolRespons
id: workflow.id,
name: workflow.name,
active: workflow.active,
isArchived: workflow.isArchived,
createdAt: workflow.createdAt,
updatedAt: workflow.updatedAt,
tags: workflow.tags || [],

View File

@@ -725,46 +725,21 @@ export class N8NDocumentationMCPServer {
case 'get_node_as_tool_info':
this.validateToolParams(name, args, ['nodeType']);
return this.getNodeAsToolInfo(args.nodeType);
case 'list_templates':
// No required params
const listLimit = Math.min(Math.max(Number(args.limit) || 10, 1), 100);
const listOffset = Math.max(Number(args.offset) || 0, 0);
const sortBy = args.sortBy || 'views';
const includeMetadata = Boolean(args.includeMetadata);
return this.listTemplates(listLimit, listOffset, sortBy, includeMetadata);
case 'list_node_templates':
this.validateToolParams(name, args, ['nodeTypes']);
const templateLimit = Math.min(Math.max(Number(args.limit) || 10, 1), 100);
const templateOffset = Math.max(Number(args.offset) || 0, 0);
return this.listNodeTemplates(args.nodeTypes, templateLimit, templateOffset);
const templateLimit = args.limit !== undefined ? Number(args.limit) || 10 : 10;
return this.listNodeTemplates(args.nodeTypes, templateLimit);
case 'get_template':
this.validateToolParams(name, args, ['templateId']);
const templateId = Number(args.templateId);
const mode = args.mode || 'full';
return this.getTemplate(templateId, mode);
return this.getTemplate(templateId);
case 'search_templates':
this.validateToolParams(name, args, ['query']);
const searchLimit = Math.min(Math.max(Number(args.limit) || 20, 1), 100);
const searchOffset = Math.max(Number(args.offset) || 0, 0);
const searchFields = args.fields as string[] | undefined;
return this.searchTemplates(args.query, searchLimit, searchOffset, searchFields);
const searchLimit = args.limit !== undefined ? Number(args.limit) || 20 : 20;
return this.searchTemplates(args.query, searchLimit);
case 'get_templates_for_task':
this.validateToolParams(name, args, ['task']);
const taskLimit = Math.min(Math.max(Number(args.limit) || 10, 1), 100);
const taskOffset = Math.max(Number(args.offset) || 0, 0);
return this.getTemplatesForTask(args.task, taskLimit, taskOffset);
case 'search_templates_by_metadata':
// No required params - all filters are optional
const metadataLimit = Math.min(Math.max(Number(args.limit) || 20, 1), 100);
const metadataOffset = Math.max(Number(args.offset) || 0, 0);
return this.searchTemplatesByMetadata({
category: args.category,
complexity: args.complexity,
maxSetupMinutes: args.maxSetupMinutes ? Number(args.maxSetupMinutes) : undefined,
minSetupMinutes: args.minSetupMinutes ? Number(args.minSetupMinutes) : undefined,
requiredService: args.requiredService,
targetAudience: args.targetAudience
}, metadataLimit, metadataOffset);
return this.getTemplatesForTask(args.task);
case 'validate_workflow':
this.validateToolParams(name, args, ['workflow']);
return this.validateWorkflow(args.workflow, args.options);
@@ -1619,19 +1594,8 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
GROUP BY package_name
`).all() as any[];
// Get template statistics
const templateStats = this.db!.prepare(`
SELECT
COUNT(*) as total_templates,
AVG(views) as avg_views,
MIN(views) as min_views,
MAX(views) as max_views
FROM templates
`).get() as any;
return {
totalNodes: stats.total,
totalTemplates: templateStats.total_templates || 0,
statistics: {
aiTools: stats.ai_tools,
triggers: stats.triggers,
@@ -1640,12 +1604,6 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
documentationCoverage: Math.round((stats.with_docs / stats.total) * 100) + '%',
uniquePackages: stats.packages,
uniqueCategories: stats.categories,
templates: {
total: templateStats.total_templates || 0,
avgViews: Math.round(templateStats.avg_views || 0),
minViews: templateStats.min_views || 0,
maxViews: templateStats.max_views || 0
}
},
packageBreakdown: packages.map(pkg => ({
package: pkg.package_name,
@@ -2342,95 +2300,76 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
}
// Template-related methods
private async listTemplates(limit: number = 10, offset: number = 0, sortBy: 'views' | 'created_at' | 'name' = 'views', includeMetadata: boolean = false): Promise<any> {
private async listNodeTemplates(nodeTypes: string[], limit: number = 10): Promise<any> {
await this.ensureInitialized();
if (!this.templateService) throw new Error('Template service not initialized');
const result = await this.templateService.listTemplates(limit, offset, sortBy, includeMetadata);
const templates = await this.templateService.listNodeTemplates(nodeTypes, limit);
return {
...result,
tip: result.items.length > 0 ?
`Use get_template(templateId) to get full workflow details. Total: ${result.total} templates available.` :
"No templates found. Run 'npm run fetch:templates' to update template database"
};
}
private async listNodeTemplates(nodeTypes: string[], limit: number = 10, offset: number = 0): Promise<any> {
await this.ensureInitialized();
if (!this.templateService) throw new Error('Template service not initialized');
const result = await this.templateService.listNodeTemplates(nodeTypes, limit, offset);
if (result.items.length === 0 && offset === 0) {
if (templates.length === 0) {
return {
...result,
message: `No templates found using nodes: ${nodeTypes.join(', ')}`,
tip: "Try searching with more common nodes or run 'npm run fetch:templates' to update template database"
tip: "Try searching with more common nodes or run 'npm run fetch:templates' to update template database",
templates: []
};
}
return {
...result,
tip: `Showing ${result.items.length} of ${result.total} templates. Use offset for pagination.`
templates,
count: templates.length,
tip: `Use get_template(templateId) to get the full workflow JSON for any template`
};
}
private async getTemplate(templateId: number, mode: 'nodes_only' | 'structure' | 'full' = 'full'): Promise<any> {
private async getTemplate(templateId: number): Promise<any> {
await this.ensureInitialized();
if (!this.templateService) throw new Error('Template service not initialized');
const template = await this.templateService.getTemplate(templateId, mode);
const template = await this.templateService.getTemplate(templateId);
if (!template) {
return {
error: `Template ${templateId} not found`,
tip: "Use list_templates, list_node_templates or search_templates to find available templates"
tip: "Use list_node_templates or search_templates to find available templates"
};
}
const usage = mode === 'nodes_only' ? "Node list for quick overview" :
mode === 'structure' ? "Workflow structure without full details" :
"Complete workflow JSON ready to import into n8n";
return {
mode,
template,
usage
usage: "Import this workflow JSON directly into n8n or use it as a reference for building workflows"
};
}
private async searchTemplates(query: string, limit: number = 20, offset: number = 0, fields?: string[]): Promise<any> {
private async searchTemplates(query: string, limit: number = 20): Promise<any> {
await this.ensureInitialized();
if (!this.templateService) throw new Error('Template service not initialized');
const result = await this.templateService.searchTemplates(query, limit, offset, fields);
const templates = await this.templateService.searchTemplates(query, limit);
if (result.items.length === 0 && offset === 0) {
if (templates.length === 0) {
return {
...result,
message: `No templates found matching: "${query}"`,
tip: "Try different keywords or run 'npm run fetch:templates' to update template database"
tip: "Try different keywords or run 'npm run fetch:templates' to update template database",
templates: []
};
}
return {
...result,
query,
tip: `Found ${result.total} templates matching "${query}". Showing ${result.items.length}.`
templates,
count: templates.length,
query
};
}
private async getTemplatesForTask(task: string, limit: number = 10, offset: number = 0): Promise<any> {
private async getTemplatesForTask(task: string): Promise<any> {
await this.ensureInitialized();
if (!this.templateService) throw new Error('Template service not initialized');
const result = await this.templateService.getTemplatesForTask(task, limit, offset);
const templates = await this.templateService.getTemplatesForTask(task);
const availableTasks = this.templateService.listAvailableTasks();
if (result.items.length === 0 && offset === 0) {
if (templates.length === 0) {
return {
...result,
message: `No templates found for task: ${task}`,
availableTasks,
tip: "Try a different task or use search_templates for custom searches"
@@ -2438,54 +2377,10 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
}
return {
...result,
task,
description: this.getTaskDescription(task),
tip: `${result.total} templates available for ${task}. Showing ${result.items.length}.`
};
}
private async searchTemplatesByMetadata(filters: {
category?: string;
complexity?: 'simple' | 'medium' | 'complex';
maxSetupMinutes?: number;
minSetupMinutes?: number;
requiredService?: string;
targetAudience?: string;
}, limit: number = 20, offset: number = 0): Promise<any> {
await this.ensureInitialized();
if (!this.templateService) throw new Error('Template service not initialized');
const result = await this.templateService.searchTemplatesByMetadata(filters, limit, offset);
// Build filter summary for feedback
const filterSummary: string[] = [];
if (filters.category) filterSummary.push(`category: ${filters.category}`);
if (filters.complexity) filterSummary.push(`complexity: ${filters.complexity}`);
if (filters.maxSetupMinutes) filterSummary.push(`max setup: ${filters.maxSetupMinutes} min`);
if (filters.minSetupMinutes) filterSummary.push(`min setup: ${filters.minSetupMinutes} min`);
if (filters.requiredService) filterSummary.push(`service: ${filters.requiredService}`);
if (filters.targetAudience) filterSummary.push(`audience: ${filters.targetAudience}`);
if (result.items.length === 0 && offset === 0) {
// Get available categories and audiences for suggestions
const availableCategories = await this.templateService.getAvailableCategories();
const availableAudiences = await this.templateService.getAvailableTargetAudiences();
return {
...result,
message: `No templates found with filters: ${filterSummary.join(', ')}`,
availableCategories: availableCategories.slice(0, 10),
availableAudiences: availableAudiences.slice(0, 5),
tip: "Try broader filters or different categories. Use list_templates to see all templates."
};
}
return {
...result,
filters,
filterSummary: filterSummary.join(', '),
tip: `Found ${result.total} templates matching filters. Showing ${result.items.length}. Each includes AI-generated metadata.`
templates,
count: templates.length,
description: this.getTaskDescription(task)
};
}

View File

@@ -22,8 +22,7 @@ import {
getNodeForTaskDoc,
listNodeTemplatesDoc,
getTemplateDoc,
searchTemplatesDoc,
searchTemplatesByMetadataDoc,
searchTemplatesDoc,
getTemplatesForTaskDoc
} from './templates';
import {
@@ -84,7 +83,6 @@ export const toolsDocumentation: Record<string, ToolDocumentation> = {
list_node_templates: listNodeTemplatesDoc,
get_template: getTemplateDoc,
search_templates: searchTemplatesDoc,
search_templates_by_metadata: searchTemplatesByMetadataDoc,
get_templates_for_task: getTemplatesForTaskDoc,
// Workflow Management tools (n8n API)

View File

@@ -3,5 +3,4 @@ export { listTasksDoc } from './list-tasks';
export { listNodeTemplatesDoc } from './list-node-templates';
export { getTemplateDoc } from './get-template';
export { searchTemplatesDoc } from './search-templates';
export { searchTemplatesByMetadataDoc } from './search-templates-by-metadata';
export { getTemplatesForTaskDoc } from './get-templates-for-task';

View File

@@ -1,118 +0,0 @@
import { ToolDocumentation } from '../types';
export const searchTemplatesByMetadataDoc: ToolDocumentation = {
name: 'search_templates_by_metadata',
category: 'templates',
essentials: {
description: 'Search templates using AI-generated metadata filters. Find templates by complexity, setup time, required services, or target audience. Enables smart template discovery beyond simple text search.',
keyParameters: ['category', 'complexity', 'maxSetupMinutes', 'targetAudience'],
example: 'search_templates_by_metadata({complexity: "simple", maxSetupMinutes: 30})',
performance: 'Fast (<100ms) - JSON extraction queries',
tips: [
'All filters are optional - combine them for precise results',
'Use getAvailableCategories() to see valid category values',
'Complexity levels: simple, medium, complex',
'Setup time is in minutes (5-480 range)'
]
},
full: {
description: `Advanced template search using AI-generated metadata. Each template has been analyzed by GPT-4 to extract structured information about its purpose, complexity, setup requirements, and target users. This enables intelligent filtering beyond simple keyword matching, helping you find templates that match your specific needs, skill level, and available time.`,
parameters: {
category: {
type: 'string',
required: false,
description: 'Filter by category like "automation", "integration", "data processing", "communication". Use template service getAvailableCategories() for full list.'
},
complexity: {
type: 'string (enum)',
required: false,
description: 'Filter by implementation complexity: "simple" (beginner-friendly), "medium" (some experience needed), or "complex" (advanced features)'
},
maxSetupMinutes: {
type: 'number',
required: false,
description: 'Maximum acceptable setup time in minutes (5-480). Find templates you can implement within your time budget.'
},
minSetupMinutes: {
type: 'number',
required: false,
description: 'Minimum setup time in minutes (5-480). Find more substantial templates that offer comprehensive solutions.'
},
requiredService: {
type: 'string',
required: false,
description: 'Filter by required external service like "openai", "slack", "google", "shopify". Ensures you have necessary accounts/APIs.'
},
targetAudience: {
type: 'string',
required: false,
description: 'Filter by intended users: "developers", "marketers", "analysts", "operations", "sales". Find templates for your role.'
},
limit: {
type: 'number',
required: false,
description: 'Maximum results to return. Default 20, max 100.'
},
offset: {
type: 'number',
required: false,
description: 'Pagination offset for results. Default 0.'
}
},
returns: `Returns an object containing:
- items: Array of matching templates with full metadata
- id: Template ID
- name: Template name
- description: Purpose and functionality
- author: Creator details
- nodes: Array of nodes used
- views: Popularity count
- metadata: AI-generated structured data
- categories: Primary use categories
- complexity: Difficulty level
- use_cases: Specific applications
- estimated_setup_minutes: Time to implement
- required_services: External dependencies
- key_features: Main capabilities
- target_audience: Intended users
- total: Total matching templates
- filters: Applied filter criteria
- filterSummary: Human-readable filter description
- availableCategories: Suggested categories if no results
- availableAudiences: Suggested audiences if no results
- tip: Contextual guidance`,
examples: [
'search_templates_by_metadata({complexity: "simple"}) - Find beginner-friendly templates',
'search_templates_by_metadata({category: "automation", maxSetupMinutes: 30}) - Quick automation templates',
'search_templates_by_metadata({targetAudience: "marketers"}) - Marketing-focused workflows',
'search_templates_by_metadata({requiredService: "openai", complexity: "medium"}) - AI templates with moderate complexity',
'search_templates_by_metadata({minSetupMinutes: 60, category: "integration"}) - Comprehensive integration solutions'
],
useCases: [
'Finding beginner-friendly templates by setting complexity:"simple"',
'Discovering templates you can implement quickly with maxSetupMinutes:30',
'Finding role-specific workflows with targetAudience filter',
'Identifying templates that need specific APIs with requiredService filter',
'Combining multiple filters for precise template discovery'
],
performance: 'Fast (<100ms) - Uses SQLite JSON extraction on pre-generated metadata. 97.5% coverage (2,534/2,598 templates).',
bestPractices: [
'Start with broad filters and narrow down based on results',
'Use getAvailableCategories() to discover valid category values',
'Combine complexity and setup time for skill-appropriate templates',
'Check required services before selecting templates to ensure you have necessary accounts'
],
pitfalls: [
'Not all templates have metadata (97.5% coverage)',
'Setup time estimates assume basic n8n familiarity',
'Categories/audiences use partial matching - be specific',
'Metadata is AI-generated and may occasionally be imprecise'
],
relatedTools: [
'list_templates',
'search_templates',
'list_node_templates',
'get_templates_for_task'
]
}
};

View File

@@ -5,14 +5,13 @@ export const searchTemplatesDoc: ToolDocumentation = {
category: 'templates',
essentials: {
description: 'Search templates by name/description keywords. NOT for node types! For nodes use list_node_templates. Example: "chatbot".',
keyParameters: ['query', 'limit', 'fields'],
example: 'search_templates({query: "chatbot", fields: ["id", "name"]})',
keyParameters: ['query', 'limit'],
example: 'search_templates({query: "chatbot"})',
performance: 'Fast (<100ms) - FTS5 full-text search',
tips: [
'Searches template names and descriptions, NOT node types',
'Use keywords like "automation", "sync", "notification"',
'For node-specific search, use list_node_templates instead',
'Use fields parameter to get only specific data (reduces response by 70-90%)'
'For node-specific search, use list_node_templates instead'
]
},
full: {
@@ -23,11 +22,6 @@ export const searchTemplatesDoc: ToolDocumentation = {
required: true,
description: 'Search query for template names/descriptions. NOT for node types! Examples: "chatbot", "automation", "social media", "webhook". For node-based search use list_node_templates instead.'
},
fields: {
type: 'array',
required: false,
description: 'Fields to include in response. Options: "id", "name", "description", "author", "nodes", "views", "created", "url", "metadata". Default: all fields. Example: ["id", "name"] for minimal response.'
},
limit: {
type: 'number',
required: false,
@@ -53,9 +47,7 @@ export const searchTemplatesDoc: ToolDocumentation = {
'search_templates({query: "email notification"}) - Find email alert workflows',
'search_templates({query: "data sync"}) - Find data synchronization workflows',
'search_templates({query: "webhook automation", limit: 30}) - Find webhook-based automations',
'search_templates({query: "social media scheduler"}) - Find social posting workflows',
'search_templates({query: "slack", fields: ["id", "name"]}) - Get only IDs and names of Slack templates',
'search_templates({query: "automation", fields: ["id", "name", "description"]}) - Get minimal info for automation templates'
'search_templates({query: "social media scheduler"}) - Find social posting workflows'
],
useCases: [
'Find workflows by business purpose',

View File

@@ -323,42 +323,9 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
required: ['nodeType'],
},
},
{
name: 'list_templates',
description: `List all templates with minimal data (id, name, description, views, node count). Optionally include AI-generated metadata for smart filtering.`,
inputSchema: {
type: 'object',
properties: {
limit: {
type: 'number',
description: 'Number of results (1-100). Default 10.',
default: 10,
minimum: 1,
maximum: 100,
},
offset: {
type: 'number',
description: 'Pagination offset. Default 0.',
default: 0,
minimum: 0,
},
sortBy: {
type: 'string',
enum: ['views', 'created_at', 'name'],
description: 'Sort field. Default: views (popularity).',
default: 'views',
},
includeMetadata: {
type: 'boolean',
description: 'Include AI-generated metadata (categories, complexity, setup time, etc.). Default false.',
default: false,
},
},
},
},
{
name: 'list_node_templates',
description: `Find templates using specific nodes. Returns paginated results. Use FULL types: "n8n-nodes-base.httpRequest".`,
description: `Find templates using specific nodes. 399 community workflows. Use FULL types: "n8n-nodes-base.httpRequest".`,
inputSchema: {
type: 'object',
properties: {
@@ -371,14 +338,6 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
type: 'number',
description: 'Maximum number of templates to return. Default 10.',
default: 10,
minimum: 1,
maximum: 100,
},
offset: {
type: 'number',
description: 'Pagination offset. Default 0.',
default: 0,
minimum: 0,
},
},
required: ['nodeTypes'],
@@ -386,7 +345,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
},
{
name: 'get_template',
description: `Get template by ID. Use mode to control response size: nodes_only (minimal), structure (nodes+connections), full (complete workflow).`,
description: `Get complete workflow JSON by ID. Ready to import. IDs from list_node_templates or search_templates.`,
inputSchema: {
type: 'object',
properties: {
@@ -394,19 +353,13 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
type: 'number',
description: 'The template ID to retrieve',
},
mode: {
type: 'string',
enum: ['nodes_only', 'structure', 'full'],
description: 'Response detail level. nodes_only: just node list, structure: nodes+connections, full: complete workflow JSON.',
default: 'full',
},
},
required: ['templateId'],
},
},
{
name: 'search_templates',
description: `Search templates by name/description keywords. Returns paginated results. NOT for node types! For nodes use list_node_templates.`,
description: `Search templates by name/description keywords. NOT for node types! For nodes use list_node_templates. Example: "chatbot".`,
inputSchema: {
type: 'object',
properties: {
@@ -414,26 +367,10 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
type: 'string',
description: 'Search keyword as string. Example: "chatbot"',
},
fields: {
type: 'array',
items: {
type: 'string',
enum: ['id', 'name', 'description', 'author', 'nodes', 'views', 'created', 'url', 'metadata'],
},
description: 'Fields to include in response. Default: all fields. Example: ["id", "name"] for minimal response.',
},
limit: {
type: 'number',
description: 'Maximum number of results. Default 20.',
default: 20,
minimum: 1,
maximum: 100,
},
offset: {
type: 'number',
description: 'Pagination offset. Default 0.',
default: 0,
minimum: 0,
},
},
required: ['query'],
@@ -441,7 +378,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
},
{
name: 'get_templates_for_task',
description: `Curated templates by task. Returns paginated results sorted by popularity.`,
description: `Curated templates by task: ai_automation, data_sync, webhooks, email, slack, data_transform, files, scheduling, api, database.`,
inputSchema: {
type: 'object',
properties: {
@@ -461,75 +398,10 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
],
description: 'The type of task to get templates for',
},
limit: {
type: 'number',
description: 'Maximum number of results. Default 10.',
default: 10,
minimum: 1,
maximum: 100,
},
offset: {
type: 'number',
description: 'Pagination offset. Default 0.',
default: 0,
minimum: 0,
},
},
required: ['task'],
},
},
{
name: 'search_templates_by_metadata',
description: `Search templates by AI-generated metadata. Filter by category, complexity, setup time, services, or audience. Returns rich metadata for smart template discovery.`,
inputSchema: {
type: 'object',
properties: {
category: {
type: 'string',
description: 'Filter by category (e.g., "automation", "integration", "data processing")',
},
complexity: {
type: 'string',
enum: ['simple', 'medium', 'complex'],
description: 'Filter by complexity level',
},
maxSetupMinutes: {
type: 'number',
description: 'Maximum setup time in minutes',
minimum: 5,
maximum: 480,
},
minSetupMinutes: {
type: 'number',
description: 'Minimum setup time in minutes',
minimum: 5,
maximum: 480,
},
requiredService: {
type: 'string',
description: 'Filter by required service (e.g., "openai", "slack", "google")',
},
targetAudience: {
type: 'string',
description: 'Filter by target audience (e.g., "developers", "marketers", "analysts")',
},
limit: {
type: 'number',
description: 'Maximum number of results. Default 20.',
default: 20,
minimum: 1,
maximum: 100,
},
offset: {
type: 'number',
description: 'Pagination offset. Default 0.',
default: 0,
minimum: 0,
},
},
additionalProperties: false,
},
},
{
name: 'validate_workflow',
description: `Full workflow validation: structure, connections, expressions, AI tools. Returns errors/warnings/fixes. Essential before deploy.`,

View File

@@ -3,41 +3,9 @@ import { createDatabaseAdapter } from '../database/database-adapter';
import { TemplateService } from '../templates/template-service';
import * as fs from 'fs';
import * as path from 'path';
import * as zlib from 'zlib';
import * as dotenv from 'dotenv';
import type { MetadataRequest } from '../templates/metadata-generator';
// Load environment variables
dotenv.config();
async function fetchTemplates(mode: 'rebuild' | 'update' = 'rebuild', generateMetadata: boolean = false, metadataOnly: boolean = false) {
// If metadata-only mode, skip template fetching entirely
if (metadataOnly) {
console.log('🤖 Metadata-only mode: Generating metadata for existing templates...\n');
if (!process.env.OPENAI_API_KEY) {
console.error('❌ OPENAI_API_KEY not set in environment');
process.exit(1);
}
const db = await createDatabaseAdapter('./data/nodes.db');
const service = new TemplateService(db);
await generateTemplateMetadata(db, service);
if ('close' in db && typeof db.close === 'function') {
db.close();
}
return;
}
const modeEmoji = mode === 'rebuild' ? '🔄' : '⬆️';
const modeText = mode === 'rebuild' ? 'Rebuilding' : 'Updating';
console.log(`${modeEmoji} ${modeText} n8n workflow templates...\n`);
if (generateMetadata) {
console.log('🤖 Metadata generation enabled (using OpenAI)\n');
}
async function fetchTemplates() {
console.log('🌐 Fetching n8n workflow templates...\n');
// Ensure data directory exists
const dataDir = './data';
@@ -48,47 +16,61 @@ async function fetchTemplates(mode: 'rebuild' | 'update' = 'rebuild', generateMe
// Initialize database
const db = await createDatabaseAdapter('./data/nodes.db');
// Handle database schema based on mode
if (mode === 'rebuild') {
try {
// Drop existing tables in rebuild mode
db.exec('DROP TABLE IF EXISTS templates');
db.exec('DROP TABLE IF EXISTS templates_fts');
console.log('🗑️ Dropped existing templates tables (rebuild mode)\n');
// Apply fresh schema
const schema = fs.readFileSync(path.join(__dirname, '../../src/database/schema.sql'), 'utf8');
db.exec(schema);
console.log('📋 Applied database schema\n');
} catch (error) {
console.error('❌ Error setting up database schema:', error);
throw error;
}
} else {
console.log('📊 Update mode: Keeping existing templates and schema\n');
// In update mode, only ensure new columns exist (for migration)
try {
// Check if metadata columns exist, add them if not (migration support)
const columns = db.prepare("PRAGMA table_info(templates)").all() as any[];
const hasMetadataColumn = columns.some((col: any) => col.name === 'metadata_json');
if (!hasMetadataColumn) {
console.log('📋 Adding metadata columns to existing schema...');
db.exec(`
ALTER TABLE templates ADD COLUMN metadata_json TEXT;
ALTER TABLE templates ADD COLUMN metadata_generated_at DATETIME;
`);
console.log('✅ Metadata columns added\n');
}
} catch (error) {
// Columns might already exist, that's fine
console.log('📋 Schema is up to date\n');
}
// Drop existing templates table to ensure clean schema
try {
db.exec('DROP TABLE IF EXISTS templates');
db.exec('DROP TABLE IF EXISTS templates_fts');
console.log('🗑️ Dropped existing templates tables\n');
} catch (error) {
// Ignore errors if tables don't exist
}
// FTS5 initialization is handled by TemplateRepository
// No need to duplicate the logic here
// Apply schema with updated constraint
const schema = fs.readFileSync(path.join(__dirname, '../../src/database/schema.sql'), 'utf8');
db.exec(schema);
// Pre-create FTS5 tables if supported
const hasFTS5 = db.checkFTS5Support();
if (hasFTS5) {
console.log('🔍 Creating FTS5 tables for template search...');
try {
// Create FTS5 virtual table
db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS templates_fts USING fts5(
name, description, content=templates
);
`);
// Create triggers to keep FTS5 in sync
db.exec(`
CREATE TRIGGER IF NOT EXISTS templates_ai AFTER INSERT ON templates BEGIN
INSERT INTO templates_fts(rowid, name, description)
VALUES (new.id, new.name, new.description);
END;
`);
db.exec(`
CREATE TRIGGER IF NOT EXISTS templates_au AFTER UPDATE ON templates BEGIN
UPDATE templates_fts SET name = new.name, description = new.description
WHERE rowid = new.id;
END;
`);
db.exec(`
CREATE TRIGGER IF NOT EXISTS templates_ad AFTER DELETE ON templates BEGIN
DELETE FROM templates_fts WHERE rowid = old.id;
END;
`);
console.log('✅ FTS5 tables created successfully\n');
} catch (error) {
console.log('⚠️ Failed to create FTS5 tables:', error);
console.log(' Template search will use LIKE fallback\n');
}
} else {
console.log(' FTS5 not supported in this SQLite build');
console.log(' Template search will use LIKE queries\n');
}
// Create service
const service = new TemplateService(db);
@@ -104,10 +86,10 @@ async function fetchTemplates(mode: 'rebuild' | 'update' = 'rebuild', generateMe
process.stdout.write('\r' + ' '.repeat(lastMessage.length) + '\r');
}
const progress = total > 0 ? Math.round((current / total) * 100) : 0;
const progress = Math.round((current / total) * 100);
lastMessage = `📊 ${message}: ${current}/${total} (${progress}%)`;
process.stdout.write(lastMessage);
}, mode); // Pass the mode parameter!
});
console.log('\n'); // New line after progress
@@ -126,14 +108,6 @@ async function fetchTemplates(mode: 'rebuild' | 'update' = 'rebuild', generateMe
console.log(` ${index + 1}. ${node.node} (${node.count} templates)`);
});
// Generate metadata if requested
if (generateMetadata && process.env.OPENAI_API_KEY) {
console.log('\n🤖 Generating metadata for templates...');
await generateTemplateMetadata(db, service);
} else if (generateMetadata && !process.env.OPENAI_API_KEY) {
console.log('\n⚠ Metadata generation requested but OPENAI_API_KEY not set');
}
} catch (error) {
console.error('\n❌ Error fetching templates:', error);
process.exit(1);
@@ -145,151 +119,9 @@ async function fetchTemplates(mode: 'rebuild' | 'update' = 'rebuild', generateMe
}
}
// Generate metadata for templates using OpenAI
async function generateTemplateMetadata(db: any, service: TemplateService) {
try {
const { BatchProcessor } = await import('../templates/batch-processor');
const repository = (service as any).repository;
// Get templates without metadata (0 = no limit)
const limit = parseInt(process.env.METADATA_LIMIT || '0');
const templatesWithoutMetadata = limit > 0
? repository.getTemplatesWithoutMetadata(limit)
: repository.getTemplatesWithoutMetadata(999999); // Get all
if (templatesWithoutMetadata.length === 0) {
console.log('✅ All templates already have metadata');
return;
}
console.log(`Found ${templatesWithoutMetadata.length} templates without metadata`);
// Create batch processor
const batchSize = parseInt(process.env.OPENAI_BATCH_SIZE || '50');
console.log(`Processing in batches of ${batchSize} templates each`);
// Warn if batch size is very large
if (batchSize > 100) {
console.log(`⚠️ Large batch size (${batchSize}) may take longer to process`);
console.log(` Consider using OPENAI_BATCH_SIZE=50 for faster results`);
}
const processor = new BatchProcessor({
apiKey: process.env.OPENAI_API_KEY!,
model: process.env.OPENAI_MODEL || 'gpt-4o-mini',
batchSize: batchSize,
outputDir: './temp/batch'
});
// Prepare metadata requests
const requests: MetadataRequest[] = templatesWithoutMetadata.map((t: any) => {
let workflow = undefined;
try {
if (t.workflow_json_compressed) {
const decompressed = zlib.gunzipSync(Buffer.from(t.workflow_json_compressed, 'base64'));
workflow = JSON.parse(decompressed.toString());
} else if (t.workflow_json) {
workflow = JSON.parse(t.workflow_json);
}
} catch (error) {
console.warn(`Failed to parse workflow for template ${t.id}:`, error);
}
return {
templateId: t.id,
name: t.name,
description: t.description,
nodes: JSON.parse(t.nodes_used),
workflow
};
});
// Process in batches
const results = await processor.processTemplates(requests, (message, current, total) => {
process.stdout.write(`\r📊 ${message}: ${current}/${total}`);
});
console.log('\n');
// Update database with metadata
const metadataMap = new Map();
for (const [templateId, result] of results) {
if (!result.error) {
metadataMap.set(templateId, result.metadata);
}
}
if (metadataMap.size > 0) {
repository.batchUpdateMetadata(metadataMap);
console.log(`✅ Updated metadata for ${metadataMap.size} templates`);
}
// Show stats
const stats = repository.getMetadataStats();
console.log('\n📈 Metadata Statistics:');
console.log(` - Total templates: ${stats.total}`);
console.log(` - With metadata: ${stats.withMetadata}`);
console.log(` - Without metadata: ${stats.withoutMetadata}`);
console.log(` - Outdated (>30 days): ${stats.outdated}`);
} catch (error) {
console.error('\n❌ Error generating metadata:', error);
}
}
// Parse command line arguments
function parseArgs(): { mode: 'rebuild' | 'update', generateMetadata: boolean, metadataOnly: boolean } {
const args = process.argv.slice(2);
let mode: 'rebuild' | 'update' = 'rebuild';
let generateMetadata = false;
let metadataOnly = false;
// Check for --mode flag
const modeIndex = args.findIndex(arg => arg.startsWith('--mode'));
if (modeIndex !== -1) {
const modeArg = args[modeIndex];
const modeValue = modeArg.includes('=') ? modeArg.split('=')[1] : args[modeIndex + 1];
if (modeValue === 'update') {
mode = 'update';
}
}
// Check for --update flag as shorthand
if (args.includes('--update')) {
mode = 'update';
}
// Check for --generate-metadata flag
if (args.includes('--generate-metadata') || args.includes('--metadata')) {
generateMetadata = true;
}
// Check for --metadata-only flag
if (args.includes('--metadata-only')) {
metadataOnly = true;
}
// Show help if requested
if (args.includes('--help') || args.includes('-h')) {
console.log('Usage: npm run fetch:templates [options]\n');
console.log('Options:');
console.log(' --mode=rebuild|update Rebuild from scratch or update existing (default: rebuild)');
console.log(' --update Shorthand for --mode=update');
console.log(' --generate-metadata Generate AI metadata after fetching templates');
console.log(' --metadata Shorthand for --generate-metadata');
console.log(' --metadata-only Only generate metadata, skip template fetching');
console.log(' --help, -h Show this help message');
process.exit(0);
}
return { mode, generateMetadata, metadataOnly };
}
// Run if called directly
if (require.main === module) {
const { mode, generateMetadata, metadataOnly } = parseArgs();
fetchTemplates(mode, generateMetadata, metadataOnly).catch(console.error);
fetchTemplates().catch(console.error);
}
export { fetchTemplates };

View File

@@ -5,7 +5,7 @@
*/
import { createDatabaseAdapter } from '../database/database-adapter';
import { N8nNodeLoader } from '../loaders/node-loader';
import { NodeParser, ParsedNode } from '../parsers/node-parser';
import { NodeParser } from '../parsers/node-parser';
import { DocsMapper } from '../mappers/docs-mapper';
import { NodeRepository } from '../database/node-repository';
import { TemplateSanitizer } from '../utils/template-sanitizer';
@@ -46,10 +46,7 @@ async function rebuild() {
withDocs: 0
};
// Process each node (documentation fetching must be outside transaction due to async)
console.log('🔄 Processing nodes...');
const processedNodes: Array<{ parsed: ParsedNode; docs: string | undefined; nodeName: string }> = [];
// Process each node
for (const { packageName, nodeName, NodeClass } of nodes) {
try {
// Parse node
@@ -57,34 +54,15 @@ async function rebuild() {
// Validate parsed data
if (!parsed.nodeType || !parsed.displayName) {
throw new Error(`Missing required fields - nodeType: ${parsed.nodeType}, displayName: ${parsed.displayName}, packageName: ${parsed.packageName}`);
}
// Additional validation for required fields
if (!parsed.packageName) {
throw new Error(`Missing packageName for node ${nodeName}`);
throw new Error('Missing required fields');
}
// Get documentation
const docs = await mapper.fetchDocumentation(parsed.nodeType);
parsed.documentation = docs || undefined;
processedNodes.push({ parsed, docs: docs || undefined, nodeName });
} catch (error) {
stats.failed++;
const errorMessage = (error as Error).message;
console.error(`❌ Failed to process ${nodeName}: ${errorMessage}`);
}
}
// Now save all processed nodes to database
console.log(`\n💾 Saving ${processedNodes.length} processed nodes to database...`);
let saved = 0;
for (const { parsed, docs, nodeName } of processedNodes) {
try {
// Save to database
repository.saveNode(parsed);
saved++;
// Update statistics
stats.successful++;
@@ -98,28 +76,13 @@ async function rebuild() {
console.log(`${parsed.nodeType} [Props: ${parsed.properties.length}, Ops: ${parsed.operations.length}]`);
} catch (error) {
stats.failed++;
const errorMessage = (error as Error).message;
console.error(`❌ Failed to save ${nodeName}: ${errorMessage}`);
console.error(`❌ Failed to process ${nodeName}: ${(error as Error).message}`);
}
}
console.log(`💾 Save completed: ${saved} nodes saved successfully`);
// Validation check
console.log('\n🔍 Running validation checks...');
try {
const validationResults = validateDatabase(repository);
if (!validationResults.passed) {
console.log('⚠️ Validation Issues:');
validationResults.issues.forEach(issue => console.log(` - ${issue}`));
} else {
console.log('✅ All validation checks passed');
}
} catch (validationError) {
console.error('❌ Validation failed:', (validationError as Error).message);
console.log('⚠️ Skipping validation due to database compatibility issues');
}
const validationResults = validateDatabase(repository);
// Summary
console.log('\n📊 Summary:');
@@ -133,6 +96,11 @@ async function rebuild() {
console.log(` With Operations: ${stats.withOperations}`);
console.log(` With Documentation: ${stats.withDocs}`);
if (!validationResults.passed) {
console.log('\n⚠ Validation Issues:');
validationResults.issues.forEach(issue => console.log(` - ${issue}`));
}
// Sanitize templates if they exist
console.log('\n🧹 Checking for templates to sanitize...');
const templateCount = db.prepare('SELECT COUNT(*) as count FROM templates').get() as { count: number };

View File

@@ -52,7 +52,7 @@ async function runValidationSummary() {
for (const template of templates) {
try {
const workflow = JSON.parse(template.workflow_json || '{}');
const workflow = JSON.parse(template.workflow_json);
const validationResult = await validator.validateWorkflow(workflow, {
profile: 'minimal' // Use minimal profile to focus on critical errors
});

View File

@@ -1,379 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
import OpenAI from 'openai';
import { logger } from '../utils/logger';
import { MetadataGenerator, MetadataRequest, MetadataResult } from './metadata-generator';
export interface BatchProcessorOptions {
apiKey: string;
model?: string;
batchSize?: number;
outputDir?: string;
}
export interface BatchJob {
id: string;
status: 'validating' | 'in_progress' | 'finalizing' | 'completed' | 'failed' | 'expired' | 'cancelled';
created_at: number;
completed_at?: number;
input_file_id: string;
output_file_id?: string;
error?: any;
}
export class BatchProcessor {
private client: OpenAI;
private generator: MetadataGenerator;
private batchSize: number;
private outputDir: string;
constructor(options: BatchProcessorOptions) {
this.client = new OpenAI({ apiKey: options.apiKey });
this.generator = new MetadataGenerator(options.apiKey, options.model);
this.batchSize = options.batchSize || 100;
this.outputDir = options.outputDir || './temp';
// Ensure output directory exists
if (!fs.existsSync(this.outputDir)) {
fs.mkdirSync(this.outputDir, { recursive: true });
}
}
/**
* Process templates in batches (parallel submission)
*/
async processTemplates(
templates: MetadataRequest[],
progressCallback?: (message: string, current: number, total: number) => void
): Promise<Map<number, MetadataResult>> {
const results = new Map<number, MetadataResult>();
const batches = this.createBatches(templates);
logger.info(`Processing ${templates.length} templates in ${batches.length} batches`);
// Submit all batches in parallel
console.log(`\n📤 Submitting ${batches.length} batch${batches.length > 1 ? 'es' : ''} to OpenAI...`);
const batchJobs: Array<{ batchNum: number; jobPromise: Promise<any>; templates: MetadataRequest[] }> = [];
for (let i = 0; i < batches.length; i++) {
const batch = batches[i];
const batchNum = i + 1;
try {
progressCallback?.(`Submitting batch ${batchNum}/${batches.length}`, i * this.batchSize, templates.length);
// Submit batch (don't wait for completion)
const jobPromise = this.submitBatch(batch, `batch_${batchNum}`);
batchJobs.push({ batchNum, jobPromise, templates: batch });
console.log(` 📨 Submitted batch ${batchNum}/${batches.length} (${batch.length} templates)`);
} catch (error) {
logger.error(`Error submitting batch ${batchNum}:`, error);
console.error(` ❌ Failed to submit batch ${batchNum}`);
}
}
console.log(`\n⏳ All batches submitted. Waiting for completion...`);
console.log(` (Batches process in parallel - this is much faster than sequential processing)`);
// Process all batches in parallel and collect results as they complete
const batchPromises = batchJobs.map(async ({ batchNum, jobPromise, templates: batchTemplates }) => {
try {
const completedJob = await jobPromise;
console.log(`\n📦 Retrieving results for batch ${batchNum}/${batches.length}...`);
// Retrieve and parse results
const batchResults = await this.retrieveResults(completedJob);
logger.info(`Retrieved ${batchResults.length} results from batch ${batchNum}`);
progressCallback?.(`Retrieved batch ${batchNum}/${batches.length}`,
Math.min(batchNum * this.batchSize, templates.length), templates.length);
return { batchNum, results: batchResults };
} catch (error) {
logger.error(`Error processing batch ${batchNum}:`, error);
console.error(` ❌ Batch ${batchNum} failed:`, error);
return { batchNum, results: [] };
}
});
// Wait for all batches to complete
const allBatchResults = await Promise.all(batchPromises);
// Merge all results
for (const { batchNum, results: batchResults } of allBatchResults) {
for (const result of batchResults) {
results.set(result.templateId, result);
}
if (batchResults.length > 0) {
console.log(` ✅ Merged ${batchResults.length} results from batch ${batchNum}`);
}
}
logger.info(`Batch processing complete: ${results.size} results`);
return results;
}
/**
* Submit a batch without waiting for completion
*/
private async submitBatch(templates: MetadataRequest[], batchName: string): Promise<any> {
// Create JSONL file
const inputFile = await this.createBatchFile(templates, batchName);
try {
// Upload file to OpenAI
const uploadedFile = await this.uploadFile(inputFile);
// Create batch job
const batchJob = await this.createBatchJob(uploadedFile.id);
// Start monitoring (returns promise that resolves when complete)
const monitoringPromise = this.monitorBatchJob(batchJob.id);
// Clean up input file immediately
try {
fs.unlinkSync(inputFile);
} catch {}
// Store file IDs for cleanup later
monitoringPromise.then(async (completedJob) => {
// Cleanup uploaded files after completion
try {
await this.client.files.del(uploadedFile.id);
if (completedJob.output_file_id) {
// Note: We'll delete output file after retrieving results
}
} catch (error) {
logger.warn(`Failed to cleanup files for batch ${batchName}`, error);
}
});
return monitoringPromise;
} catch (error) {
// Cleanup on error
try {
fs.unlinkSync(inputFile);
} catch {}
throw error;
}
}
/**
* Process a single batch
*/
private async processBatch(templates: MetadataRequest[], batchName: string): Promise<MetadataResult[]> {
// Create JSONL file
const inputFile = await this.createBatchFile(templates, batchName);
try {
// Upload file to OpenAI
const uploadedFile = await this.uploadFile(inputFile);
// Create batch job
const batchJob = await this.createBatchJob(uploadedFile.id);
// Monitor job until completion
const completedJob = await this.monitorBatchJob(batchJob.id);
// Retrieve and parse results
const results = await this.retrieveResults(completedJob);
// Cleanup
await this.cleanup(inputFile, uploadedFile.id, completedJob.output_file_id);
return results;
} catch (error) {
// Cleanup on error
try {
fs.unlinkSync(inputFile);
} catch {}
throw error;
}
}
/**
* Create batches from templates
*/
private createBatches(templates: MetadataRequest[]): MetadataRequest[][] {
const batches: MetadataRequest[][] = [];
for (let i = 0; i < templates.length; i += this.batchSize) {
batches.push(templates.slice(i, i + this.batchSize));
}
return batches;
}
/**
* Create JSONL batch file
*/
private async createBatchFile(templates: MetadataRequest[], batchName: string): Promise<string> {
const filename = path.join(this.outputDir, `${batchName}_${Date.now()}.jsonl`);
const stream = fs.createWriteStream(filename);
for (const template of templates) {
const request = this.generator.createBatchRequest(template);
stream.write(JSON.stringify(request) + '\n');
}
stream.end();
// Wait for stream to finish
await new Promise<void>((resolve, reject) => {
stream.on('finish', () => resolve());
stream.on('error', reject);
});
logger.debug(`Created batch file: ${filename} with ${templates.length} requests`);
return filename;
}
/**
* Upload file to OpenAI
*/
private async uploadFile(filepath: string): Promise<any> {
const file = fs.createReadStream(filepath);
const uploadedFile = await this.client.files.create({
file,
purpose: 'batch'
});
logger.debug(`Uploaded file: ${uploadedFile.id}`);
return uploadedFile;
}
/**
* Create batch job
*/
private async createBatchJob(fileId: string): Promise<any> {
const batchJob = await this.client.batches.create({
input_file_id: fileId,
endpoint: '/v1/chat/completions',
completion_window: '24h'
});
logger.info(`Created batch job: ${batchJob.id}`);
return batchJob;
}
/**
* Monitor batch job with exponential backoff
*/
private async monitorBatchJob(batchId: string): Promise<any> {
// Start with shorter wait times for better UX
const waitTimes = [30, 60, 120, 300, 600, 900, 1800]; // Progressive wait times in seconds
let waitIndex = 0;
let attempts = 0;
const maxAttempts = 100; // Safety limit
const startTime = Date.now();
let lastStatus = '';
while (attempts < maxAttempts) {
const batchJob = await this.client.batches.retrieve(batchId);
// Only log if status changed
if (batchJob.status !== lastStatus) {
const elapsedMinutes = Math.floor((Date.now() - startTime) / 60000);
const statusSymbol = batchJob.status === 'in_progress' ? '⚙️' :
batchJob.status === 'finalizing' ? '📦' :
batchJob.status === 'validating' ? '🔍' : '⏳';
console.log(` ${statusSymbol} Batch ${batchId.slice(-8)}: ${batchJob.status} (${elapsedMinutes} min)`);
lastStatus = batchJob.status;
}
logger.debug(`Batch ${batchId} status: ${batchJob.status} (attempt ${attempts + 1})`);
if (batchJob.status === 'completed') {
const elapsedMinutes = Math.floor((Date.now() - startTime) / 60000);
console.log(` ✅ Batch ${batchId.slice(-8)} completed in ${elapsedMinutes} minutes`);
logger.info(`Batch job ${batchId} completed successfully`);
return batchJob;
}
if (['failed', 'expired', 'cancelled'].includes(batchJob.status)) {
throw new Error(`Batch job failed with status: ${batchJob.status}`);
}
// Wait before next check
const waitTime = waitTimes[Math.min(waitIndex, waitTimes.length - 1)];
logger.debug(`Waiting ${waitTime} seconds before next check...`);
await this.sleep(waitTime * 1000);
waitIndex = Math.min(waitIndex + 1, waitTimes.length - 1);
attempts++;
}
throw new Error(`Batch job monitoring timed out after ${maxAttempts} attempts`);
}
/**
* Retrieve and parse results
*/
private async retrieveResults(batchJob: any): Promise<MetadataResult[]> {
if (!batchJob.output_file_id) {
throw new Error('No output file available for batch job');
}
// Download result file
const fileResponse = await this.client.files.content(batchJob.output_file_id);
const fileContent = await fileResponse.text();
// Parse JSONL results
const results: MetadataResult[] = [];
const lines = fileContent.trim().split('\n');
for (const line of lines) {
if (!line) continue;
try {
const result = JSON.parse(line);
const parsed = this.generator.parseResult(result);
results.push(parsed);
} catch (error) {
logger.error('Error parsing result line:', error);
}
}
logger.info(`Retrieved ${results.length} results from batch job`);
return results;
}
/**
* Cleanup temporary files
*/
private async cleanup(localFile: string, inputFileId: string, outputFileId?: string): Promise<void> {
// Delete local file
try {
fs.unlinkSync(localFile);
logger.debug(`Deleted local file: ${localFile}`);
} catch (error) {
logger.warn(`Failed to delete local file: ${localFile}`, error);
}
// Delete uploaded files from OpenAI
try {
await this.client.files.del(inputFileId);
logger.debug(`Deleted input file from OpenAI: ${inputFileId}`);
} catch (error) {
logger.warn(`Failed to delete input file from OpenAI: ${inputFileId}`, error);
}
if (outputFileId) {
try {
await this.client.files.del(outputFileId);
logger.debug(`Deleted output file from OpenAI: ${outputFileId}`);
} catch (error) {
logger.warn(`Failed to delete output file from OpenAI: ${outputFileId}`, error);
}
}
}
/**
* Sleep helper
*/
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

View File

@@ -1,322 +0,0 @@
import OpenAI from 'openai';
import { z } from 'zod';
import { logger } from '../utils/logger';
import { TemplateWorkflow, TemplateDetail } from './template-fetcher';
// Metadata schema using Zod for validation
export const TemplateMetadataSchema = z.object({
categories: z.array(z.string()).max(5).describe('Main categories (max 5)'),
complexity: z.enum(['simple', 'medium', 'complex']).describe('Implementation complexity'),
use_cases: z.array(z.string()).max(5).describe('Primary use cases'),
estimated_setup_minutes: z.number().min(5).max(480).describe('Setup time in minutes'),
required_services: z.array(z.string()).describe('External services needed'),
key_features: z.array(z.string()).max(5).describe('Main capabilities'),
target_audience: z.array(z.string()).max(3).describe('Target users')
});
export type TemplateMetadata = z.infer<typeof TemplateMetadataSchema>;
export interface MetadataRequest {
templateId: number;
name: string;
description?: string;
nodes: string[];
workflow?: any;
}
export interface MetadataResult {
templateId: number;
metadata: TemplateMetadata;
error?: string;
}
export class MetadataGenerator {
private client: OpenAI;
private model: string;
constructor(apiKey: string, model: string = 'gpt-4o-mini') {
this.client = new OpenAI({ apiKey });
this.model = model;
}
/**
* Generate the JSON schema for OpenAI structured outputs
*/
private getJsonSchema() {
return {
name: 'template_metadata',
strict: true,
schema: {
type: 'object',
properties: {
categories: {
type: 'array',
items: { type: 'string' },
maxItems: 5,
description: 'Main categories like automation, integration, data processing'
},
complexity: {
type: 'string',
enum: ['simple', 'medium', 'complex'],
description: 'Implementation complexity level'
},
use_cases: {
type: 'array',
items: { type: 'string' },
maxItems: 5,
description: 'Primary use cases for this template'
},
estimated_setup_minutes: {
type: 'number',
minimum: 5,
maximum: 480,
description: 'Estimated setup time in minutes'
},
required_services: {
type: 'array',
items: { type: 'string' },
description: 'External services or APIs required'
},
key_features: {
type: 'array',
items: { type: 'string' },
maxItems: 5,
description: 'Main capabilities or features'
},
target_audience: {
type: 'array',
items: { type: 'string' },
maxItems: 3,
description: 'Target users like developers, marketers, analysts'
}
},
required: [
'categories',
'complexity',
'use_cases',
'estimated_setup_minutes',
'required_services',
'key_features',
'target_audience'
],
additionalProperties: false
}
};
}
/**
* Create a batch request for a single template
*/
createBatchRequest(template: MetadataRequest): any {
// Extract node information for analysis
const nodesSummary = this.summarizeNodes(template.nodes);
// Sanitize template name and description to prevent prompt injection
// Allow longer names for test scenarios but still sanitize content
const sanitizedName = this.sanitizeInput(template.name, Math.max(200, template.name.length));
const sanitizedDescription = template.description ?
this.sanitizeInput(template.description, 500) : '';
// Build context for the AI with sanitized inputs
const context = [
`Template: ${sanitizedName}`,
sanitizedDescription ? `Description: ${sanitizedDescription}` : '',
`Nodes Used (${template.nodes.length}): ${nodesSummary}`,
template.workflow ? `Workflow has ${template.workflow.nodes?.length || 0} nodes with ${Object.keys(template.workflow.connections || {}).length} connections` : ''
].filter(Boolean).join('\n');
return {
custom_id: `template-${template.templateId}`,
method: 'POST',
url: '/v1/chat/completions',
body: {
model: this.model,
temperature: 0.3, // Lower temperature for more consistent structured outputs
max_completion_tokens: 1000,
response_format: {
type: 'json_schema',
json_schema: this.getJsonSchema()
},
messages: [
{
role: 'system',
content: `Analyze n8n workflow templates and extract metadata. Be concise.`
},
{
role: 'user',
content: context
}
]
}
};
}
/**
* Sanitize input to prevent prompt injection and control token usage
*/
private sanitizeInput(input: string, maxLength: number): string {
// Truncate to max length
let sanitized = input.slice(0, maxLength);
// Remove control characters and excessive whitespace
sanitized = sanitized.replace(/[\x00-\x1F\x7F-\x9F]/g, '');
// Replace multiple spaces/newlines with single space
sanitized = sanitized.replace(/\s+/g, ' ').trim();
// Remove potential prompt injection patterns
sanitized = sanitized.replace(/\b(system|assistant|user|human|ai):/gi, '');
sanitized = sanitized.replace(/```[\s\S]*?```/g, ''); // Remove code blocks
sanitized = sanitized.replace(/\[INST\]|\[\/INST\]/g, ''); // Remove instruction markers
return sanitized;
}
/**
* Summarize nodes for better context
*/
private summarizeNodes(nodes: string[]): string {
// Group similar nodes
const nodeGroups: Record<string, number> = {};
for (const node of nodes) {
// Extract base node name (remove package prefix)
const baseName = node.split('.').pop() || node;
// Group by category
if (baseName.includes('webhook') || baseName.includes('http')) {
nodeGroups['HTTP/Webhooks'] = (nodeGroups['HTTP/Webhooks'] || 0) + 1;
} else if (baseName.includes('database') || baseName.includes('postgres') || baseName.includes('mysql')) {
nodeGroups['Database'] = (nodeGroups['Database'] || 0) + 1;
} else if (baseName.includes('slack') || baseName.includes('email') || baseName.includes('gmail')) {
nodeGroups['Communication'] = (nodeGroups['Communication'] || 0) + 1;
} else if (baseName.includes('ai') || baseName.includes('openai') || baseName.includes('langchain') ||
baseName.toLowerCase().includes('openai') || baseName.includes('agent')) {
nodeGroups['AI/ML'] = (nodeGroups['AI/ML'] || 0) + 1;
} else if (baseName.includes('sheet') || baseName.includes('csv') || baseName.includes('excel') ||
baseName.toLowerCase().includes('googlesheets')) {
nodeGroups['Spreadsheets'] = (nodeGroups['Spreadsheets'] || 0) + 1;
} else {
// For unmatched nodes, try to use a meaningful name
// If it's a special node name with dots, preserve the meaningful part
let displayName;
if (node.includes('.with.') && node.includes('@')) {
// Special case for node names like '@n8n/custom-node.with.dots'
displayName = node.split('/').pop() || baseName;
} else {
// Use the full base name for normal unknown nodes
// Only clean obvious suffixes, not when they're part of meaningful names
if (baseName.endsWith('Trigger') && baseName.length > 7) {
displayName = baseName.slice(0, -7); // Remove 'Trigger'
} else if (baseName.endsWith('Node') && baseName.length > 4 && baseName !== 'unknownNode') {
displayName = baseName.slice(0, -4); // Remove 'Node' only if it's not the main name
} else {
displayName = baseName; // Keep the full name
}
}
nodeGroups[displayName] = (nodeGroups[displayName] || 0) + 1;
}
}
// Format summary
const summary = Object.entries(nodeGroups)
.sort((a, b) => b[1] - a[1])
.slice(0, 10) // Top 10 groups
.map(([name, count]) => count > 1 ? `${name} (${count})` : name)
.join(', ');
return summary;
}
/**
* Parse a batch result
*/
parseResult(result: any): MetadataResult {
try {
if (result.error) {
return {
templateId: parseInt(result.custom_id.replace('template-', '')),
metadata: this.getDefaultMetadata(),
error: result.error.message
};
}
const response = result.response;
if (!response?.body?.choices?.[0]?.message?.content) {
throw new Error('Invalid response structure');
}
const content = response.body.choices[0].message.content;
const metadata = JSON.parse(content);
// Validate with Zod
const validated = TemplateMetadataSchema.parse(metadata);
return {
templateId: parseInt(result.custom_id.replace('template-', '')),
metadata: validated
};
} catch (error) {
logger.error(`Error parsing result for ${result.custom_id}:`, error);
return {
templateId: parseInt(result.custom_id.replace('template-', '')),
metadata: this.getDefaultMetadata(),
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
/**
* Get default metadata for fallback
*/
private getDefaultMetadata(): TemplateMetadata {
return {
categories: ['automation'],
complexity: 'medium',
use_cases: ['Process automation'],
estimated_setup_minutes: 30,
required_services: [],
key_features: ['Workflow automation'],
target_audience: ['developers']
};
}
/**
* Generate metadata for a single template (for testing)
*/
async generateSingle(template: MetadataRequest): Promise<TemplateMetadata> {
try {
const completion = await this.client.chat.completions.create({
model: this.model,
temperature: 0.3, // Lower temperature for more consistent structured outputs
max_completion_tokens: 1000,
response_format: {
type: 'json_schema',
json_schema: this.getJsonSchema()
} as any,
messages: [
{
role: 'system',
content: `Analyze n8n workflow templates and extract metadata. Be concise.`
},
{
role: 'user',
content: `Template: ${template.name}\nNodes: ${template.nodes.slice(0, 10).join(', ')}`
}
]
});
const content = completion.choices[0].message.content;
if (!content) {
logger.error('No content in OpenAI response');
throw new Error('No content in response');
}
const metadata = JSON.parse(content);
return TemplateMetadataSchema.parse(metadata);
} catch (error) {
logger.error('Error generating single metadata:', error);
return this.getDefaultMetadata();
}
}
}

View File

@@ -39,75 +39,58 @@ export interface TemplateDetail {
export class TemplateFetcher {
private readonly baseUrl = 'https://api.n8n.io/api/templates';
private readonly pageSize = 250; // Maximum allowed by API
private readonly pageSize = 100;
/**
* Fetch all templates and filter to last 12 months
* This fetches ALL pages first, then applies date filter locally
*/
async fetchTemplates(progressCallback?: (current: number, total: number) => void): Promise<TemplateWorkflow[]> {
const allTemplates = await this.fetchAllTemplates(progressCallback);
// Apply date filter locally after fetching all
const oneYearAgo = new Date();
oneYearAgo.setMonth(oneYearAgo.getMonth() - 12);
const recentTemplates = allTemplates.filter((w: TemplateWorkflow) => {
const createdDate = new Date(w.createdAt);
return createdDate >= oneYearAgo;
});
logger.info(`Filtered to ${recentTemplates.length} templates from last 12 months (out of ${allTemplates.length} total)`);
return recentTemplates;
}
/**
* Fetch ALL templates from the API without date filtering
* Used internally and can be used for other filtering strategies
*/
async fetchAllTemplates(progressCallback?: (current: number, total: number) => void): Promise<TemplateWorkflow[]> {
const allTemplates: TemplateWorkflow[] = [];
let page = 1;
let hasMore = true;
let totalWorkflows = 0;
logger.info('Starting complete template fetch from n8n.io API');
logger.info('Starting template fetch from n8n.io API');
while (hasMore) {
try {
const response = await axios.get(`${this.baseUrl}/search`, {
params: {
page,
rows: this.pageSize
// Note: sort_by parameter doesn't work, templates come in popularity order
rows: this.pageSize,
sort_by: 'last-updated'
}
});
const { workflows } = response.data;
totalWorkflows = response.data.totalWorkflows || totalWorkflows;
const { workflows, totalWorkflows } = response.data;
allTemplates.push(...workflows);
// Filter templates by date
const recentTemplates = workflows.filter((w: TemplateWorkflow) => {
const createdDate = new Date(w.createdAt);
return createdDate >= oneYearAgo;
});
// Calculate total pages for better progress reporting
const totalPages = Math.ceil(totalWorkflows / this.pageSize);
if (progressCallback) {
// Enhanced progress with page information
progressCallback(allTemplates.length, totalWorkflows);
// If we hit templates older than 1 year, stop fetching
if (recentTemplates.length < workflows.length) {
hasMore = false;
logger.info(`Reached templates older than 1 year at page ${page}`);
}
logger.debug(`Fetched page ${page}/${totalPages}: ${workflows.length} templates (total so far: ${allTemplates.length}/${totalWorkflows})`);
allTemplates.push(...recentTemplates);
if (progressCallback) {
progressCallback(allTemplates.length, Math.min(totalWorkflows, allTemplates.length + 500));
}
// Check if there are more pages
if (workflows.length < this.pageSize) {
if (workflows.length < this.pageSize || allTemplates.length >= totalWorkflows) {
hasMore = false;
}
page++;
// Rate limiting - be nice to the API (slightly faster with 250 rows/page)
// Rate limiting - be nice to the API
if (hasMore) {
await this.sleep(300); // 300ms between requests (was 500ms with 100 rows)
await this.sleep(500); // 500ms between requests
}
} catch (error) {
logger.error(`Error fetching templates page ${page}:`, error);
@@ -115,7 +98,7 @@ export class TemplateFetcher {
}
}
logger.info(`Fetched all ${allTemplates.length} templates from n8n.io`);
logger.info(`Fetched ${allTemplates.length} templates from last year`);
return allTemplates;
}
@@ -148,8 +131,8 @@ export class TemplateFetcher {
progressCallback(i + 1, workflows.length);
}
// Rate limiting (conservative to avoid API throttling)
await this.sleep(150); // 150ms between requests
// Rate limiting
await this.sleep(200); // 200ms between requests
} catch (error) {
logger.error(`Failed to fetch details for workflow ${workflow.id}:`, error);
// Continue with other templates

View File

@@ -2,8 +2,6 @@ import { DatabaseAdapter } from '../database/database-adapter';
import { TemplateWorkflow, TemplateDetail } from './template-fetcher';
import { logger } from '../utils/logger';
import { TemplateSanitizer } from '../utils/template-sanitizer';
import * as zlib from 'zlib';
import { resolveTemplateNodeTypes } from '../utils/template-node-resolver';
export interface StoredTemplate {
id: number;
@@ -14,16 +12,13 @@ export interface StoredTemplate {
author_username: string;
author_verified: number;
nodes_used: string; // JSON string
workflow_json?: string; // JSON string (deprecated)
workflow_json_compressed?: string; // Base64 encoded gzip
workflow_json: string; // JSON string
categories: string; // JSON string
views: number;
created_at: string;
updated_at: string;
url: string;
scraped_at: string;
metadata_json?: string; // Structured metadata from OpenAI (JSON string)
metadata_generated_at?: string; // When metadata was generated
}
export class TemplateRepository {
@@ -110,16 +105,10 @@ export class TemplateRepository {
* Save a template to the database
*/
saveTemplate(workflow: TemplateWorkflow, detail: TemplateDetail, categories: string[] = []): void {
// Filter out templates with 10 or fewer views
if ((workflow.totalViews || 0) <= 10) {
logger.debug(`Skipping template ${workflow.id}: ${workflow.name} (only ${workflow.totalViews} views)`);
return;
}
const stmt = this.db.prepare(`
INSERT OR REPLACE INTO templates (
id, workflow_id, name, description, author_name, author_username,
author_verified, nodes_used, workflow_json_compressed, categories, views,
author_verified, nodes_used, workflow_json, categories, views,
created_at, updated_at, url
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
@@ -144,17 +133,6 @@ export class TemplateRepository {
});
}
// Compress the workflow JSON
const workflowJsonStr = JSON.stringify(sanitizedWorkflow);
const compressed = zlib.gzipSync(workflowJsonStr);
const compressedBase64 = compressed.toString('base64');
// Log compression ratio
const originalSize = Buffer.byteLength(workflowJsonStr);
const compressedSize = compressed.length;
const ratio = Math.round((1 - compressedSize / originalSize) * 100);
logger.debug(`Template ${workflow.id} compression: ${originalSize}${compressedSize} bytes (${ratio}% reduction)`);
stmt.run(
workflow.id,
workflow.id,
@@ -164,7 +142,7 @@ export class TemplateRepository {
workflow.user.username,
workflow.user.verified ? 1 : 0,
JSON.stringify(nodeTypes),
compressedBase64,
JSON.stringify(sanitizedWorkflow),
JSON.stringify(categories),
workflow.totalViews || 0,
workflow.createdAt,
@@ -176,34 +154,18 @@ export class TemplateRepository {
/**
* Get templates that use specific node types
*/
getTemplatesByNodes(nodeTypes: string[], limit: number = 10, offset: number = 0): StoredTemplate[] {
// Resolve input node types to all possible template formats
const resolvedTypes = resolveTemplateNodeTypes(nodeTypes);
if (resolvedTypes.length === 0) {
logger.debug('No resolved types for template search', { input: nodeTypes });
return [];
}
getTemplatesByNodes(nodeTypes: string[], limit: number = 10): StoredTemplate[] {
// Build query for multiple node types
const conditions = resolvedTypes.map(() => "nodes_used LIKE ?").join(" OR ");
const conditions = nodeTypes.map(() => "nodes_used LIKE ?").join(" OR ");
const query = `
SELECT * FROM templates
WHERE ${conditions}
ORDER BY views DESC, created_at DESC
LIMIT ? OFFSET ?
LIMIT ?
`;
const params = [...resolvedTypes.map(n => `%"${n}"%`), limit, offset];
const results = this.db.prepare(query).all(...params) as StoredTemplate[];
logger.debug(`Template search found ${results.length} results`, {
input: nodeTypes,
resolved: resolvedTypes,
found: results.length
});
return results.map(t => this.decompressWorkflow(t));
const params = [...nodeTypes.map(n => `%"${n}"%`), limit];
return this.db.prepare(query).all(...params) as StoredTemplate[];
}
/**
@@ -214,49 +176,19 @@ export class TemplateRepository {
SELECT * FROM templates WHERE id = ?
`).get(templateId) as StoredTemplate | undefined;
if (!row) return null;
// Decompress workflow JSON if compressed
if (row.workflow_json_compressed && !row.workflow_json) {
try {
const compressed = Buffer.from(row.workflow_json_compressed, 'base64');
const decompressed = zlib.gunzipSync(compressed);
row.workflow_json = decompressed.toString();
} catch (error) {
logger.error(`Failed to decompress workflow for template ${templateId}:`, error);
return null;
}
}
return row;
}
/**
* Decompress workflow JSON for a template
*/
private decompressWorkflow(template: StoredTemplate): StoredTemplate {
if (template.workflow_json_compressed && !template.workflow_json) {
try {
const compressed = Buffer.from(template.workflow_json_compressed, 'base64');
const decompressed = zlib.gunzipSync(compressed);
template.workflow_json = decompressed.toString();
} catch (error) {
logger.error(`Failed to decompress workflow for template ${template.id}:`, error);
}
}
return template;
return row || null;
}
/**
* Search templates by name or description
*/
searchTemplates(query: string, limit: number = 20, offset: number = 0): StoredTemplate[] {
searchTemplates(query: string, limit: number = 20): StoredTemplate[] {
logger.debug(`Searching templates for: "${query}" (FTS5: ${this.hasFTS5Support})`);
// If FTS5 is not supported, go straight to LIKE search
if (!this.hasFTS5Support) {
logger.debug('Using LIKE search (FTS5 not available)');
return this.searchTemplatesLIKE(query, limit, offset);
return this.searchTemplatesLIKE(query, limit);
}
try {
@@ -273,11 +205,11 @@ export class TemplateRepository {
JOIN templates_fts ON t.id = templates_fts.rowid
WHERE templates_fts MATCH ?
ORDER BY rank, t.views DESC
LIMIT ? OFFSET ?
`).all(ftsQuery, limit, offset) as StoredTemplate[];
LIMIT ?
`).all(ftsQuery, limit) as StoredTemplate[];
logger.debug(`FTS5 search returned ${results.length} results`);
return results.map(t => this.decompressWorkflow(t));
return results;
} catch (error: any) {
// If FTS5 query fails, fallback to LIKE search
logger.warn('FTS5 template search failed, using LIKE fallback:', {
@@ -285,14 +217,14 @@ export class TemplateRepository {
query: query,
ftsQuery: query.split(' ').map(term => `"${term}"`).join(' OR ')
});
return this.searchTemplatesLIKE(query, limit, offset);
return this.searchTemplatesLIKE(query, limit);
}
}
/**
* Fallback search using LIKE when FTS5 is not available
*/
private searchTemplatesLIKE(query: string, limit: number = 20, offset: number = 0): StoredTemplate[] {
private searchTemplatesLIKE(query: string, limit: number = 20): StoredTemplate[] {
const likeQuery = `%${query}%`;
logger.debug(`Using LIKE search with pattern: ${likeQuery}`);
@@ -300,17 +232,17 @@ export class TemplateRepository {
SELECT * FROM templates
WHERE name LIKE ? OR description LIKE ?
ORDER BY views DESC, created_at DESC
LIMIT ? OFFSET ?
`).all(likeQuery, likeQuery, limit, offset) as StoredTemplate[];
LIMIT ?
`).all(likeQuery, likeQuery, limit) as StoredTemplate[];
logger.debug(`LIKE search returned ${results.length} results`);
return results.map(t => this.decompressWorkflow(t));
return results;
}
/**
* Get templates for a specific task/use case
*/
getTemplatesForTask(task: string, limit: number = 10, offset: number = 0): StoredTemplate[] {
getTemplatesForTask(task: string): StoredTemplate[] {
// Map tasks to relevant node combinations
const taskNodeMap: Record<string, string[]> = {
'ai_automation': ['@n8n/n8n-nodes-langchain.openAi', '@n8n/n8n-nodes-langchain.agent', 'n8n-nodes-base.openAi'],
@@ -330,22 +262,18 @@ export class TemplateRepository {
return [];
}
return this.getTemplatesByNodes(nodes, limit, offset);
return this.getTemplatesByNodes(nodes, 10);
}
/**
* Get all templates with limit
*/
getAllTemplates(limit: number = 10, offset: number = 0, sortBy: 'views' | 'created_at' | 'name' = 'views'): StoredTemplate[] {
const orderClause = sortBy === 'name' ? 'name ASC' :
sortBy === 'created_at' ? 'created_at DESC' :
'views DESC, created_at DESC';
const results = this.db.prepare(`
getAllTemplates(limit: number = 10): StoredTemplate[] {
return this.db.prepare(`
SELECT * FROM templates
ORDER BY ${orderClause}
LIMIT ? OFFSET ?
`).all(limit, offset) as StoredTemplate[];
return results.map(t => this.decompressWorkflow(t));
ORDER BY views DESC, created_at DESC
LIMIT ?
`).all(limit) as StoredTemplate[];
}
/**
@@ -356,119 +284,6 @@ export class TemplateRepository {
return result.count;
}
/**
* Get count for search results
*/
getSearchCount(query: string): number {
if (!this.hasFTS5Support) {
const likeQuery = `%${query}%`;
const result = this.db.prepare(`
SELECT COUNT(*) as count FROM templates
WHERE name LIKE ? OR description LIKE ?
`).get(likeQuery, likeQuery) as { count: number };
return result.count;
}
try {
const ftsQuery = query.split(' ').map(term => {
const escaped = term.replace(/"/g, '""');
return `"${escaped}"`;
}).join(' OR ');
const result = this.db.prepare(`
SELECT COUNT(*) as count FROM templates t
JOIN templates_fts ON t.id = templates_fts.rowid
WHERE templates_fts MATCH ?
`).get(ftsQuery) as { count: number };
return result.count;
} catch {
const likeQuery = `%${query}%`;
const result = this.db.prepare(`
SELECT COUNT(*) as count FROM templates
WHERE name LIKE ? OR description LIKE ?
`).get(likeQuery, likeQuery) as { count: number };
return result.count;
}
}
/**
* Get count for node templates
*/
getNodeTemplatesCount(nodeTypes: string[]): number {
// Resolve input node types to all possible template formats
const resolvedTypes = resolveTemplateNodeTypes(nodeTypes);
if (resolvedTypes.length === 0) {
return 0;
}
const conditions = resolvedTypes.map(() => "nodes_used LIKE ?").join(" OR ");
const query = `SELECT COUNT(*) as count FROM templates WHERE ${conditions}`;
const params = resolvedTypes.map(n => `%"${n}"%`);
const result = this.db.prepare(query).get(...params) as { count: number };
return result.count;
}
/**
* Get count for task templates
*/
getTaskTemplatesCount(task: string): number {
const taskNodeMap: Record<string, string[]> = {
'ai_automation': ['@n8n/n8n-nodes-langchain.openAi', '@n8n/n8n-nodes-langchain.agent', 'n8n-nodes-base.openAi'],
'data_sync': ['n8n-nodes-base.googleSheets', 'n8n-nodes-base.postgres', 'n8n-nodes-base.mysql'],
'webhook_processing': ['n8n-nodes-base.webhook', 'n8n-nodes-base.httpRequest'],
'email_automation': ['n8n-nodes-base.gmail', 'n8n-nodes-base.emailSend', 'n8n-nodes-base.emailReadImap'],
'slack_integration': ['n8n-nodes-base.slack', 'n8n-nodes-base.slackTrigger'],
'data_transformation': ['n8n-nodes-base.code', 'n8n-nodes-base.set', 'n8n-nodes-base.merge'],
'file_processing': ['n8n-nodes-base.readBinaryFile', 'n8n-nodes-base.writeBinaryFile', 'n8n-nodes-base.googleDrive'],
'scheduling': ['n8n-nodes-base.scheduleTrigger', 'n8n-nodes-base.cron'],
'api_integration': ['n8n-nodes-base.httpRequest', 'n8n-nodes-base.graphql'],
'database_operations': ['n8n-nodes-base.postgres', 'n8n-nodes-base.mysql', 'n8n-nodes-base.mongodb']
};
const nodes = taskNodeMap[task];
if (!nodes) {
return 0;
}
return this.getNodeTemplatesCount(nodes);
}
/**
* Get all existing template IDs for comparison
* Used in update mode to skip already fetched templates
*/
getExistingTemplateIds(): Set<number> {
const rows = this.db.prepare('SELECT id FROM templates').all() as { id: number }[];
return new Set(rows.map(r => r.id));
}
/**
* Check if a template exists in the database
*/
hasTemplate(templateId: number): boolean {
const result = this.db.prepare('SELECT 1 FROM templates WHERE id = ?').get(templateId) as { 1: number } | undefined;
return result !== undefined;
}
/**
* Get template metadata (id, name, updated_at) for all templates
* Used for comparison in update scenarios
*/
getTemplateMetadata(): Map<number, { name: string; updated_at: string }> {
const rows = this.db.prepare('SELECT id, name, updated_at FROM templates').all() as {
id: number;
name: string;
updated_at: string;
}[];
const metadata = new Map<number, { name: string; updated_at: string }>();
for (const row of rows) {
metadata.set(row.id, { name: row.name, updated_at: row.updated_at });
}
return metadata;
}
/**
* Get template statistics
*/
@@ -538,358 +353,4 @@ export class TemplateRepository {
// Non-critical error - search will fallback to LIKE
}
}
/**
* Update metadata for a template
*/
updateTemplateMetadata(templateId: number, metadata: any): void {
const stmt = this.db.prepare(`
UPDATE templates
SET metadata_json = ?, metadata_generated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
stmt.run(JSON.stringify(metadata), templateId);
logger.debug(`Updated metadata for template ${templateId}`);
}
/**
* Batch update metadata for multiple templates
*/
batchUpdateMetadata(metadataMap: Map<number, any>): void {
const stmt = this.db.prepare(`
UPDATE templates
SET metadata_json = ?, metadata_generated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
// Simple approach - just run the updates
// Most operations are fast enough without explicit transactions
for (const [templateId, metadata] of metadataMap.entries()) {
stmt.run(JSON.stringify(metadata), templateId);
}
logger.info(`Updated metadata for ${metadataMap.size} templates`);
}
/**
* Get templates without metadata
*/
getTemplatesWithoutMetadata(limit: number = 100): StoredTemplate[] {
const stmt = this.db.prepare(`
SELECT * FROM templates
WHERE metadata_json IS NULL OR metadata_generated_at IS NULL
ORDER BY views DESC
LIMIT ?
`);
return stmt.all(limit) as StoredTemplate[];
}
/**
* Get templates with outdated metadata (older than days specified)
*/
getTemplatesWithOutdatedMetadata(daysOld: number = 30, limit: number = 100): StoredTemplate[] {
const stmt = this.db.prepare(`
SELECT * FROM templates
WHERE metadata_generated_at < datetime('now', '-' || ? || ' days')
ORDER BY views DESC
LIMIT ?
`);
return stmt.all(daysOld, limit) as StoredTemplate[];
}
/**
* Get template metadata stats
*/
getMetadataStats(): {
total: number;
withMetadata: number;
withoutMetadata: number;
outdated: number;
} {
const total = this.getTemplateCount();
const withMetadata = (this.db.prepare(`
SELECT COUNT(*) as count FROM templates
WHERE metadata_json IS NOT NULL
`).get() as { count: number }).count;
const withoutMetadata = total - withMetadata;
const outdated = (this.db.prepare(`
SELECT COUNT(*) as count FROM templates
WHERE metadata_generated_at < datetime('now', '-30 days')
`).get() as { count: number }).count;
return { total, withMetadata, withoutMetadata, outdated };
}
/**
* Search templates by metadata fields
*/
searchTemplatesByMetadata(filters: {
category?: string;
complexity?: 'simple' | 'medium' | 'complex';
maxSetupMinutes?: number;
minSetupMinutes?: number;
requiredService?: string;
targetAudience?: string;
}, limit: number = 20, offset: number = 0): StoredTemplate[] {
const conditions: string[] = ['metadata_json IS NOT NULL'];
const params: any[] = [];
// Build WHERE conditions based on filters with proper parameterization
if (filters.category !== undefined) {
// Use parameterized LIKE with JSON array search - safe from injection
conditions.push("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
// Escape special characters and quotes for JSON string matching
const sanitizedCategory = JSON.stringify(filters.category).slice(1, -1);
params.push(sanitizedCategory);
}
if (filters.complexity) {
conditions.push("json_extract(metadata_json, '$.complexity') = ?");
params.push(filters.complexity);
}
if (filters.maxSetupMinutes !== undefined) {
conditions.push("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?");
params.push(filters.maxSetupMinutes);
}
if (filters.minSetupMinutes !== undefined) {
conditions.push("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?");
params.push(filters.minSetupMinutes);
}
if (filters.requiredService !== undefined) {
// Use parameterized LIKE with JSON array search - safe from injection
conditions.push("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'");
// Escape special characters and quotes for JSON string matching
const sanitizedService = JSON.stringify(filters.requiredService).slice(1, -1);
params.push(sanitizedService);
}
if (filters.targetAudience !== undefined) {
// Use parameterized LIKE with JSON array search - safe from injection
conditions.push("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'");
// Escape special characters and quotes for JSON string matching
const sanitizedAudience = JSON.stringify(filters.targetAudience).slice(1, -1);
params.push(sanitizedAudience);
}
const query = `
SELECT * FROM templates
WHERE ${conditions.join(' AND ')}
ORDER BY views DESC, created_at DESC
LIMIT ? OFFSET ?
`;
params.push(limit, offset);
const results = this.db.prepare(query).all(...params) as StoredTemplate[];
logger.debug(`Metadata search found ${results.length} results`, { filters, count: results.length });
return results.map(t => this.decompressWorkflow(t));
}
/**
* Get count for metadata search results
*/
getMetadataSearchCount(filters: {
category?: string;
complexity?: 'simple' | 'medium' | 'complex';
maxSetupMinutes?: number;
minSetupMinutes?: number;
requiredService?: string;
targetAudience?: string;
}): number {
const conditions: string[] = ['metadata_json IS NOT NULL'];
const params: any[] = [];
if (filters.category !== undefined) {
// Use parameterized LIKE with JSON array search - safe from injection
conditions.push("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
const sanitizedCategory = JSON.stringify(filters.category).slice(1, -1);
params.push(sanitizedCategory);
}
if (filters.complexity) {
conditions.push("json_extract(metadata_json, '$.complexity') = ?");
params.push(filters.complexity);
}
if (filters.maxSetupMinutes !== undefined) {
conditions.push("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?");
params.push(filters.maxSetupMinutes);
}
if (filters.minSetupMinutes !== undefined) {
conditions.push("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?");
params.push(filters.minSetupMinutes);
}
if (filters.requiredService !== undefined) {
// Use parameterized LIKE with JSON array search - safe from injection
conditions.push("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'");
const sanitizedService = JSON.stringify(filters.requiredService).slice(1, -1);
params.push(sanitizedService);
}
if (filters.targetAudience !== undefined) {
// Use parameterized LIKE with JSON array search - safe from injection
conditions.push("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'");
const sanitizedAudience = JSON.stringify(filters.targetAudience).slice(1, -1);
params.push(sanitizedAudience);
}
const query = `SELECT COUNT(*) as count FROM templates WHERE ${conditions.join(' AND ')}`;
const result = this.db.prepare(query).get(...params) as { count: number };
return result.count;
}
/**
* Get unique categories from metadata
*/
getAvailableCategories(): string[] {
const results = this.db.prepare(`
SELECT DISTINCT json_extract(value, '$') as category
FROM templates, json_each(json_extract(metadata_json, '$.categories'))
WHERE metadata_json IS NOT NULL
ORDER BY category
`).all() as { category: string }[];
return results.map(r => r.category);
}
/**
* Get unique target audiences from metadata
*/
getAvailableTargetAudiences(): string[] {
const results = this.db.prepare(`
SELECT DISTINCT json_extract(value, '$') as audience
FROM templates, json_each(json_extract(metadata_json, '$.target_audience'))
WHERE metadata_json IS NOT NULL
ORDER BY audience
`).all() as { audience: string }[];
return results.map(r => r.audience);
}
/**
* Get templates by category with metadata
*/
getTemplatesByCategory(category: string, limit: number = 10, offset: number = 0): StoredTemplate[] {
const query = `
SELECT * FROM templates
WHERE metadata_json IS NOT NULL
AND json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'
ORDER BY views DESC, created_at DESC
LIMIT ? OFFSET ?
`;
// Use same sanitization as searchTemplatesByMetadata for consistency
const sanitizedCategory = JSON.stringify(category).slice(1, -1);
const results = this.db.prepare(query).all(sanitizedCategory, limit, offset) as StoredTemplate[];
return results.map(t => this.decompressWorkflow(t));
}
/**
* Get templates by complexity level
*/
getTemplatesByComplexity(complexity: 'simple' | 'medium' | 'complex', limit: number = 10, offset: number = 0): StoredTemplate[] {
const query = `
SELECT * FROM templates
WHERE metadata_json IS NOT NULL
AND json_extract(metadata_json, '$.complexity') = ?
ORDER BY views DESC, created_at DESC
LIMIT ? OFFSET ?
`;
const results = this.db.prepare(query).all(complexity, limit, offset) as StoredTemplate[];
return results.map(t => this.decompressWorkflow(t));
}
/**
* Get count of templates matching metadata search
*/
getSearchTemplatesByMetadataCount(filters: {
category?: string;
complexity?: 'simple' | 'medium' | 'complex';
maxSetupMinutes?: number;
minSetupMinutes?: number;
requiredService?: string;
targetAudience?: string;
}): number {
let sql = `
SELECT COUNT(*) as count FROM templates
WHERE metadata_json IS NOT NULL
`;
const params: any[] = [];
if (filters.category) {
sql += ` AND json_extract(metadata_json, '$.categories') LIKE ?`;
params.push(`%"${filters.category}"%`);
}
if (filters.complexity) {
sql += ` AND json_extract(metadata_json, '$.complexity') = ?`;
params.push(filters.complexity);
}
if (filters.maxSetupMinutes !== undefined) {
sql += ` AND CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?`;
params.push(filters.maxSetupMinutes);
}
if (filters.minSetupMinutes !== undefined) {
sql += ` AND CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?`;
params.push(filters.minSetupMinutes);
}
if (filters.requiredService) {
sql += ` AND json_extract(metadata_json, '$.required_services') LIKE ?`;
params.push(`%"${filters.requiredService}"%`);
}
if (filters.targetAudience) {
sql += ` AND json_extract(metadata_json, '$.target_audience') LIKE ?`;
params.push(`%"${filters.targetAudience}"%`);
}
const result = this.db.prepare(sql).get(...params) as { count: number };
return result?.count || 0;
}
/**
* Get unique categories from metadata
*/
getUniqueCategories(): string[] {
const sql = `
SELECT DISTINCT value as category
FROM templates, json_each(metadata_json, '$.categories')
WHERE metadata_json IS NOT NULL
ORDER BY category
`;
const results = this.db.prepare(sql).all() as { category: string }[];
return results.map(r => r.category);
}
/**
* Get unique target audiences from metadata
*/
getUniqueTargetAudiences(): string[] {
const sql = `
SELECT DISTINCT value as audience
FROM templates, json_each(metadata_json, '$.target_audience')
WHERE metadata_json IS NOT NULL
ORDER BY audience
`;
const results = this.db.prepare(sql).all() as { audience: string }[];
return results.map(r => r.audience);
}
}

View File

@@ -15,49 +15,12 @@ export interface TemplateInfo {
views: number;
created: string;
url: string;
metadata?: {
categories: string[];
complexity: 'simple' | 'medium' | 'complex';
use_cases: string[];
estimated_setup_minutes: number;
required_services: string[];
key_features: string[];
target_audience: string[];
};
}
export interface TemplateWithWorkflow extends TemplateInfo {
workflow: any;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
limit: number;
offset: number;
hasMore: boolean;
}
export interface TemplateMinimal {
id: number;
name: string;
description: string;
views: number;
nodeCount: number;
metadata?: {
categories: string[];
complexity: 'simple' | 'medium' | 'complex';
use_cases: string[];
estimated_setup_minutes: number;
required_services: string[];
key_features: string[];
target_audience: string[];
};
}
export type TemplateField = 'id' | 'name' | 'description' | 'author' | 'nodes' | 'views' | 'created' | 'url' | 'metadata';
export type PartialTemplateInfo = Partial<TemplateInfo>;
export class TemplateService {
private repository: TemplateRepository;
@@ -68,134 +31,40 @@ export class TemplateService {
/**
* List templates that use specific node types
*/
async listNodeTemplates(nodeTypes: string[], limit: number = 10, offset: number = 0): Promise<PaginatedResponse<TemplateInfo>> {
const templates = this.repository.getTemplatesByNodes(nodeTypes, limit, offset);
const total = this.repository.getNodeTemplatesCount(nodeTypes);
return {
items: templates.map(this.formatTemplateInfo),
total,
limit,
offset,
hasMore: offset + limit < total
};
async listNodeTemplates(nodeTypes: string[], limit: number = 10): Promise<TemplateInfo[]> {
const templates = this.repository.getTemplatesByNodes(nodeTypes, limit);
return templates.map(this.formatTemplateInfo);
}
/**
* Get a specific template with different detail levels
* Get a specific template with full workflow
*/
async getTemplate(templateId: number, mode: 'nodes_only' | 'structure' | 'full' = 'full'): Promise<any> {
async getTemplate(templateId: number): Promise<TemplateWithWorkflow | null> {
const template = this.repository.getTemplate(templateId);
if (!template) {
return null;
}
const workflow = JSON.parse(template.workflow_json || '{}');
if (mode === 'nodes_only') {
return {
id: template.id,
name: template.name,
nodes: workflow.nodes?.map((n: any) => ({
type: n.type,
name: n.name
})) || []
};
}
if (mode === 'structure') {
return {
id: template.id,
name: template.name,
nodes: workflow.nodes?.map((n: any) => ({
id: n.id,
type: n.type,
name: n.name,
position: n.position
})) || [],
connections: workflow.connections || {}
};
}
// Full mode
return {
...this.formatTemplateInfo(template),
workflow
workflow: JSON.parse(template.workflow_json)
};
}
/**
* Search templates by query
*/
async searchTemplates(query: string, limit: number = 20, offset: number = 0, fields?: string[]): Promise<PaginatedResponse<PartialTemplateInfo>> {
const templates = this.repository.searchTemplates(query, limit, offset);
const total = this.repository.getSearchCount(query);
// If fields are specified, filter the template info
const items = fields
? templates.map(t => this.formatTemplateWithFields(t, fields))
: templates.map(t => this.formatTemplateInfo(t));
return {
items,
total,
limit,
offset,
hasMore: offset + limit < total
};
async searchTemplates(query: string, limit: number = 20): Promise<TemplateInfo[]> {
const templates = this.repository.searchTemplates(query, limit);
return templates.map(this.formatTemplateInfo);
}
/**
* Get templates for a specific task
*/
async getTemplatesForTask(task: string, limit: number = 10, offset: number = 0): Promise<PaginatedResponse<TemplateInfo>> {
const templates = this.repository.getTemplatesForTask(task, limit, offset);
const total = this.repository.getTaskTemplatesCount(task);
return {
items: templates.map(this.formatTemplateInfo),
total,
limit,
offset,
hasMore: offset + limit < total
};
}
/**
* List all templates with minimal data
*/
async listTemplates(limit: number = 10, offset: number = 0, sortBy: 'views' | 'created_at' | 'name' = 'views', includeMetadata: boolean = false): Promise<PaginatedResponse<TemplateMinimal>> {
const templates = this.repository.getAllTemplates(limit, offset, sortBy);
const total = this.repository.getTemplateCount();
const items = templates.map(t => {
const item: TemplateMinimal = {
id: t.id,
name: t.name,
description: t.description, // Always include description
views: t.views,
nodeCount: JSON.parse(t.nodes_used).length
};
// Optionally include metadata
if (includeMetadata && t.metadata_json) {
try {
item.metadata = JSON.parse(t.metadata_json);
} catch (error) {
logger.warn(`Failed to parse metadata for template ${t.id}:`, error);
}
}
return item;
});
return {
items,
total,
limit,
offset,
hasMore: offset + limit < total
};
async getTemplatesForTask(task: string): Promise<TemplateInfo[]> {
const templates = this.repository.getTemplatesForTask(task);
return templates.map(this.formatTemplateInfo);
}
/**
@@ -216,87 +85,6 @@ export class TemplateService {
];
}
/**
* Search templates by metadata filters
*/
async searchTemplatesByMetadata(
filters: {
category?: string;
complexity?: 'simple' | 'medium' | 'complex';
maxSetupMinutes?: number;
minSetupMinutes?: number;
requiredService?: string;
targetAudience?: string;
},
limit: number = 20,
offset: number = 0
): Promise<PaginatedResponse<TemplateInfo>> {
const templates = this.repository.searchTemplatesByMetadata(filters, limit, offset);
const total = this.repository.getMetadataSearchCount(filters);
return {
items: templates.map(this.formatTemplateInfo.bind(this)),
total,
limit,
offset,
hasMore: offset + limit < total
};
}
/**
* Get available categories from template metadata
*/
async getAvailableCategories(): Promise<string[]> {
return this.repository.getAvailableCategories();
}
/**
* Get available target audiences from template metadata
*/
async getAvailableTargetAudiences(): Promise<string[]> {
return this.repository.getAvailableTargetAudiences();
}
/**
* Get templates by category
*/
async getTemplatesByCategory(
category: string,
limit: number = 10,
offset: number = 0
): Promise<PaginatedResponse<TemplateInfo>> {
const templates = this.repository.getTemplatesByCategory(category, limit, offset);
const total = this.repository.getMetadataSearchCount({ category });
return {
items: templates.map(this.formatTemplateInfo.bind(this)),
total,
limit,
offset,
hasMore: offset + limit < total
};
}
/**
* Get templates by complexity level
*/
async getTemplatesByComplexity(
complexity: 'simple' | 'medium' | 'complex',
limit: number = 10,
offset: number = 0
): Promise<PaginatedResponse<TemplateInfo>> {
const templates = this.repository.getTemplatesByComplexity(complexity, limit, offset);
const total = this.repository.getMetadataSearchCount({ complexity });
return {
items: templates.map(this.formatTemplateInfo.bind(this)),
total,
limit,
offset,
hasMore: offset + limit < total
};
}
/**
* Get template statistics
*/
@@ -306,59 +94,36 @@ export class TemplateService {
/**
* Fetch and update templates from n8n.io
* @param mode - 'rebuild' to clear and rebuild, 'update' to add only new templates
*/
async fetchAndUpdateTemplates(
progressCallback?: (message: string, current: number, total: number) => void,
mode: 'rebuild' | 'update' = 'rebuild'
progressCallback?: (message: string, current: number, total: number) => void
): Promise<void> {
try {
// Dynamically import fetcher only when needed (requires axios)
const { TemplateFetcher } = await import('./template-fetcher');
const fetcher = new TemplateFetcher();
// Get existing template IDs if in update mode
let existingIds: Set<number> = new Set();
if (mode === 'update') {
existingIds = this.repository.getExistingTemplateIds();
logger.info(`Update mode: Found ${existingIds.size} existing templates in database`);
} else {
// Clear existing templates in rebuild mode
this.repository.clearTemplates();
logger.info('Rebuild mode: Cleared existing templates');
}
// Clear existing templates
this.repository.clearTemplates();
// Fetch template list
logger.info(`Fetching template list from n8n.io (mode: ${mode})`);
logger.info('Fetching template list from n8n.io');
const templates = await fetcher.fetchTemplates((current, total) => {
progressCallback?.('Fetching template list', current, total);
});
logger.info(`Found ${templates.length} templates from last 12 months`);
// Filter to only new templates if in update mode
let templatesToFetch = templates;
if (mode === 'update') {
templatesToFetch = templates.filter(t => !existingIds.has(t.id));
logger.info(`Update mode: ${templatesToFetch.length} new templates to fetch (skipping ${templates.length - templatesToFetch.length} existing)`);
if (templatesToFetch.length === 0) {
logger.info('No new templates to fetch');
progressCallback?.('No new templates', 0, 0);
return;
}
}
logger.info(`Found ${templates.length} templates from last year`);
// Fetch details for each template
logger.info(`Fetching details for ${templatesToFetch.length} templates`);
const details = await fetcher.fetchAllTemplateDetails(templatesToFetch, (current, total) => {
logger.info('Fetching template details');
const details = await fetcher.fetchAllTemplateDetails(templates, (current, total) => {
progressCallback?.('Fetching template details', current, total);
});
// Save to database
logger.info('Saving templates to database');
let saved = 0;
for (const template of templatesToFetch) {
for (const template of templates) {
const detail = details.get(template.id);
if (detail) {
this.repository.saveTemplate(template, detail);
@@ -369,10 +134,8 @@ export class TemplateService {
logger.info(`Successfully saved ${saved} templates to database`);
// Rebuild FTS5 index after bulk import
if (saved > 0) {
logger.info('Rebuilding FTS5 index for templates');
this.repository.rebuildTemplateFTS();
}
logger.info('Rebuilding FTS5 index for templates');
this.repository.rebuildTemplateFTS();
progressCallback?.('Complete', saved, saved);
} catch (error) {
@@ -385,7 +148,7 @@ export class TemplateService {
* Format stored template for API response
*/
private formatTemplateInfo(template: StoredTemplate): TemplateInfo {
const info: TemplateInfo = {
return {
id: template.id,
name: template.name,
description: template.description,
@@ -399,33 +162,5 @@ export class TemplateService {
created: template.created_at,
url: template.url
};
// Include metadata if available
if (template.metadata_json) {
try {
info.metadata = JSON.parse(template.metadata_json);
} catch (error) {
logger.warn(`Failed to parse metadata for template ${template.id}:`, error);
}
}
return info;
}
/**
* Format template with only specified fields
*/
private formatTemplateWithFields(template: StoredTemplate, fields: string[]): PartialTemplateInfo {
const fullInfo = this.formatTemplateInfo(template);
const result: PartialTemplateInfo = {};
// Only include requested fields
for (const field of fields) {
if (field in fullInfo) {
(result as any)[field] = (fullInfo as any)[field];
}
}
return result;
}
}

View File

@@ -49,7 +49,6 @@ export interface Workflow {
nodes: WorkflowNode[];
connections: WorkflowConnection;
active?: boolean; // Optional for creation as it's read-only
isArchived?: boolean; // Optional, available in newer n8n versions
settings?: WorkflowSettings;
staticData?: Record<string, unknown>;
tags?: string[];

View File

@@ -1,234 +0,0 @@
import { logger } from './logger';
/**
* Resolves various node type input formats to all possible template node type formats.
* Templates store node types in full n8n format (e.g., "n8n-nodes-base.slack").
* This function handles various input formats and expands them to all possible matches.
*
* @param nodeTypes Array of node types in various formats
* @returns Array of all possible template node type formats
*
* @example
* resolveTemplateNodeTypes(['slack'])
* // Returns: ['n8n-nodes-base.slack', 'n8n-nodes-base.slackTrigger']
*
* resolveTemplateNodeTypes(['nodes-base.webhook'])
* // Returns: ['n8n-nodes-base.webhook']
*
* resolveTemplateNodeTypes(['httpRequest'])
* // Returns: ['n8n-nodes-base.httpRequest']
*/
export function resolveTemplateNodeTypes(nodeTypes: string[]): string[] {
const resolvedTypes = new Set<string>();
for (const nodeType of nodeTypes) {
// Add all variations for this node type
const variations = generateTemplateNodeVariations(nodeType);
variations.forEach(v => resolvedTypes.add(v));
}
const result = Array.from(resolvedTypes);
logger.debug(`Resolved ${nodeTypes.length} input types to ${result.length} template variations`, {
input: nodeTypes,
output: result
});
return result;
}
/**
* Generates all possible template node type variations for a single input.
*
* @param nodeType Single node type in any format
* @returns Array of possible template formats
*/
function generateTemplateNodeVariations(nodeType: string): string[] {
const variations = new Set<string>();
// If it's already in full n8n format, just return it
if (nodeType.startsWith('n8n-nodes-base.') || nodeType.startsWith('@n8n/n8n-nodes-langchain.')) {
variations.add(nodeType);
return Array.from(variations);
}
// Handle partial prefix formats (e.g., "nodes-base.slack" -> "n8n-nodes-base.slack")
if (nodeType.startsWith('nodes-base.')) {
const nodeName = nodeType.replace('nodes-base.', '');
variations.add(`n8n-nodes-base.${nodeName}`);
// Also try camelCase variations
addCamelCaseVariations(variations, nodeName, 'n8n-nodes-base');
} else if (nodeType.startsWith('nodes-langchain.')) {
const nodeName = nodeType.replace('nodes-langchain.', '');
variations.add(`@n8n/n8n-nodes-langchain.${nodeName}`);
// Also try camelCase variations
addCamelCaseVariations(variations, nodeName, '@n8n/n8n-nodes-langchain');
} else if (!nodeType.includes('.')) {
// Bare node name (e.g., "slack", "webhook", "httpRequest")
// Try both packages with various case combinations
// For n8n-nodes-base
variations.add(`n8n-nodes-base.${nodeType}`);
addCamelCaseVariations(variations, nodeType, 'n8n-nodes-base');
// For langchain (less common for bare names, but include for completeness)
variations.add(`@n8n/n8n-nodes-langchain.${nodeType}`);
addCamelCaseVariations(variations, nodeType, '@n8n/n8n-nodes-langchain');
// Add common related node types (e.g., "slack" -> also include "slackTrigger")
addRelatedNodeTypes(variations, nodeType);
}
return Array.from(variations);
}
/**
* Adds camelCase variations for a node name.
*
* @param variations Set to add variations to
* @param nodeName The node name to create variations for
* @param packagePrefix The package prefix to use
*/
function addCamelCaseVariations(variations: Set<string>, nodeName: string, packagePrefix: string): void {
const lowerName = nodeName.toLowerCase();
// Common patterns in n8n node names
const patterns = [
// Pattern: somethingTrigger (e.g., slackTrigger, webhookTrigger)
{ suffix: 'trigger', capitalize: true },
{ suffix: 'Trigger', capitalize: false },
// Pattern: somethingRequest (e.g., httpRequest)
{ suffix: 'request', capitalize: true },
{ suffix: 'Request', capitalize: false },
// Pattern: somethingDatabase (e.g., mysqlDatabase, postgresDatabase)
{ suffix: 'database', capitalize: true },
{ suffix: 'Database', capitalize: false },
// Pattern: somethingSheet/Sheets (e.g., googleSheets)
{ suffix: 'sheet', capitalize: true },
{ suffix: 'Sheet', capitalize: false },
{ suffix: 'sheets', capitalize: true },
{ suffix: 'Sheets', capitalize: false },
];
// Check if the lowercase name matches any pattern
for (const pattern of patterns) {
const lowerSuffix = pattern.suffix.toLowerCase();
if (lowerName.endsWith(lowerSuffix)) {
// Name already has the suffix, try different capitalizations
const baseName = lowerName.slice(0, -lowerSuffix.length);
if (baseName) {
if (pattern.capitalize) {
// Capitalize the suffix
const capitalizedSuffix = pattern.suffix.charAt(0).toUpperCase() + pattern.suffix.slice(1).toLowerCase();
variations.add(`${packagePrefix}.${baseName}${capitalizedSuffix}`);
} else {
// Use the suffix as-is
variations.add(`${packagePrefix}.${baseName}${pattern.suffix}`);
}
}
} else if (!lowerName.includes(lowerSuffix)) {
// Name doesn't have the suffix, try adding it
if (pattern.capitalize) {
const capitalizedSuffix = pattern.suffix.charAt(0).toUpperCase() + pattern.suffix.slice(1).toLowerCase();
variations.add(`${packagePrefix}.${lowerName}${capitalizedSuffix}`);
}
}
}
// Handle specific known cases
const specificCases: Record<string, string[]> = {
'http': ['httpRequest'],
'httprequest': ['httpRequest'],
'mysql': ['mysql', 'mysqlDatabase'],
'postgres': ['postgres', 'postgresDatabase'],
'postgresql': ['postgres', 'postgresDatabase'],
'mongo': ['mongoDb', 'mongodb'],
'mongodb': ['mongoDb', 'mongodb'],
'google': ['googleSheets', 'googleDrive', 'googleCalendar'],
'googlesheet': ['googleSheets'],
'googlesheets': ['googleSheets'],
'microsoft': ['microsoftTeams', 'microsoftExcel', 'microsoftOutlook'],
'slack': ['slack'],
'discord': ['discord'],
'telegram': ['telegram'],
'webhook': ['webhook'],
'schedule': ['scheduleTrigger'],
'cron': ['cron', 'scheduleTrigger'],
'email': ['emailSend', 'emailReadImap', 'gmail'],
'gmail': ['gmail', 'gmailTrigger'],
'code': ['code'],
'javascript': ['code'],
'python': ['code'],
'js': ['code'],
'set': ['set'],
'if': ['if'],
'switch': ['switch'],
'merge': ['merge'],
'loop': ['splitInBatches'],
'split': ['splitInBatches', 'splitOut'],
'ai': ['openAi'],
'openai': ['openAi'],
'chatgpt': ['openAi'],
'gpt': ['openAi'],
'api': ['httpRequest', 'graphql', 'webhook'],
'csv': ['spreadsheetFile', 'readBinaryFile'],
'excel': ['microsoftExcel', 'spreadsheetFile'],
'spreadsheet': ['spreadsheetFile', 'googleSheets', 'microsoftExcel'],
};
const cases = specificCases[lowerName];
if (cases) {
cases.forEach(c => variations.add(`${packagePrefix}.${c}`));
}
}
/**
* Adds related node types for common patterns.
* For example, "slack" should also include "slackTrigger".
*
* @param variations Set to add variations to
* @param nodeName The base node name
*/
function addRelatedNodeTypes(variations: Set<string>, nodeName: string): void {
const lowerName = nodeName.toLowerCase();
// Map of base names to their related node types
const relatedTypes: Record<string, string[]> = {
'slack': ['slack', 'slackTrigger'],
'gmail': ['gmail', 'gmailTrigger'],
'telegram': ['telegram', 'telegramTrigger'],
'discord': ['discord', 'discordTrigger'],
'webhook': ['webhook', 'webhookTrigger'],
'http': ['httpRequest', 'webhook'],
'email': ['emailSend', 'emailReadImap', 'gmail', 'gmailTrigger'],
'google': ['googleSheets', 'googleDrive', 'googleCalendar', 'googleDocs'],
'microsoft': ['microsoftTeams', 'microsoftExcel', 'microsoftOutlook', 'microsoftOneDrive'],
'database': ['postgres', 'mysql', 'mongoDb', 'redis', 'postgresDatabase', 'mysqlDatabase'],
'db': ['postgres', 'mysql', 'mongoDb', 'redis'],
'sql': ['postgres', 'mysql', 'mssql'],
'nosql': ['mongoDb', 'redis', 'couchDb'],
'schedule': ['scheduleTrigger', 'cron'],
'time': ['scheduleTrigger', 'cron', 'wait'],
'file': ['readBinaryFile', 'writeBinaryFile', 'moveBinaryFile'],
'binary': ['readBinaryFile', 'writeBinaryFile', 'moveBinaryFile'],
'csv': ['spreadsheetFile', 'readBinaryFile'],
'excel': ['microsoftExcel', 'spreadsheetFile'],
'json': ['code', 'set'],
'transform': ['code', 'set', 'merge', 'splitInBatches'],
'ai': ['openAi', 'agent', 'lmChatOpenAi', 'lmChatAnthropic'],
'llm': ['openAi', 'agent', 'lmChatOpenAi', 'lmChatAnthropic', 'lmChatGoogleGemini'],
'agent': ['agent', 'toolAgent'],
'chat': ['chatTrigger', 'agent'],
};
const related = relatedTypes[lowerName];
if (related) {
related.forEach(r => {
variations.add(`n8n-nodes-base.${r}`);
// Also check if it might be a langchain node
if (['agent', 'toolAgent', 'chatTrigger', 'lmChatOpenAi', 'lmChatAnthropic', 'lmChatGoogleGemini'].includes(r)) {
variations.add(`@n8n/n8n-nodes-langchain.${r}`);
}
});
}
}

View File

@@ -57,7 +57,7 @@ describe('Database Integration Tests', () => {
],
user: { id: 1, name: 'Test User', username: 'testuser', verified: false },
createdAt: new Date().toISOString(),
totalViews: 100
totalViews: 0
},
{
id: 101,
@@ -70,7 +70,7 @@ describe('Database Integration Tests', () => {
],
user: { id: 1, name: 'Test User', username: 'testuser', verified: false },
createdAt: new Date().toISOString(),
totalViews: 100
totalViews: 0
},
{
id: 102,
@@ -85,7 +85,7 @@ describe('Database Integration Tests', () => {
],
user: { id: 1, name: 'Test User', username: 'testuser', verified: false },
createdAt: new Date().toISOString(),
totalViews: 100
totalViews: 0
}
]);
});
@@ -163,8 +163,7 @@ describe('Database Integration Tests', () => {
expect(template!.name).toBe('AI Content Generator');
// Parse workflow JSON
expect(template!.workflow_json).toBeTruthy();
const workflow = JSON.parse(template!.workflow_json!);
const workflow = JSON.parse(template!.workflow_json);
expect(workflow.nodes).toHaveLength(3);
expect(workflow.nodes[0].name).toBe('Webhook');
expect(workflow.nodes[1].name).toBe('OpenAI');

View File

@@ -131,8 +131,7 @@ describe('TemplateRepository Integration Tests', () => {
const saved = repository.getTemplate(template.id);
expect(saved).toBeTruthy();
expect(saved!.workflow_json).toBeTruthy();
const workflowJson = JSON.parse(saved!.workflow_json!);
const workflowJson = JSON.parse(saved!.workflow_json);
expect(workflowJson.pinData).toBeUndefined();
});
});
@@ -240,32 +239,6 @@ describe('TemplateRepository Integration Tests', () => {
const results = repository.searchTemplates('special');
expect(results.length).toBeGreaterThan(0);
});
it('should support pagination in search results', () => {
for (let i = 1; i <= 15; i++) {
const template = createTemplateWorkflow({
id: i,
name: `Search Template ${i}`,
description: 'Common search term'
});
const detail = createTemplateDetail({ id: i });
repository.saveTemplate(template, detail);
}
const page1 = repository.searchTemplates('search', 5, 0);
expect(page1).toHaveLength(5);
const page2 = repository.searchTemplates('search', 5, 5);
expect(page2).toHaveLength(5);
const page3 = repository.searchTemplates('search', 5, 10);
expect(page3).toHaveLength(5);
// Should be different templates on each page
const page1Ids = page1.map(t => t.id);
const page2Ids = page2.map(t => t.id);
expect(page1Ids.filter(id => page2Ids.includes(id))).toHaveLength(0);
});
});
describe('getTemplatesByNodeTypes', () => {
@@ -346,17 +319,6 @@ describe('TemplateRepository Integration Tests', () => {
const results = repository.getTemplatesByNodes(['n8n-nodes-base.webhook'], 1);
expect(results).toHaveLength(1);
});
it('should support pagination with offset', () => {
const results1 = repository.getTemplatesByNodes(['n8n-nodes-base.webhook'], 1, 0);
expect(results1).toHaveLength(1);
const results2 = repository.getTemplatesByNodes(['n8n-nodes-base.webhook'], 1, 1);
expect(results2).toHaveLength(1);
// Results should be different
expect(results1[0].id).not.toBe(results2[0].id);
});
});
describe('getAllTemplates', () => {
@@ -376,51 +338,6 @@ describe('TemplateRepository Integration Tests', () => {
expect(templates).toHaveLength(10);
});
it('should support pagination with offset', () => {
for (let i = 1; i <= 15; i++) {
const template = createTemplateWorkflow({ id: i });
const detail = createTemplateDetail({ id: i });
repository.saveTemplate(template, detail);
}
const page1 = repository.getAllTemplates(5, 0);
expect(page1).toHaveLength(5);
const page2 = repository.getAllTemplates(5, 5);
expect(page2).toHaveLength(5);
const page3 = repository.getAllTemplates(5, 10);
expect(page3).toHaveLength(5);
// Should be different templates on each page
const page1Ids = page1.map(t => t.id);
const page2Ids = page2.map(t => t.id);
const page3Ids = page3.map(t => t.id);
expect(page1Ids.filter(id => page2Ids.includes(id))).toHaveLength(0);
expect(page2Ids.filter(id => page3Ids.includes(id))).toHaveLength(0);
});
it('should support different sort orders', () => {
const template1 = createTemplateWorkflow({ id: 1, name: 'Alpha Template', totalViews: 50 });
const detail1 = createTemplateDetail({ id: 1 });
repository.saveTemplate(template1, detail1);
const template2 = createTemplateWorkflow({ id: 2, name: 'Beta Template', totalViews: 100 });
const detail2 = createTemplateDetail({ id: 2 });
repository.saveTemplate(template2, detail2);
// Sort by views (default) - highest first
const byViews = repository.getAllTemplates(10, 0, 'views');
expect(byViews[0].name).toBe('Beta Template');
expect(byViews[1].name).toBe('Alpha Template');
// Sort by name - alphabetical
const byName = repository.getAllTemplates(10, 0, 'name');
expect(byName[0].name).toBe('Alpha Template');
expect(byName[1].name).toBe('Beta Template');
});
it('should order templates by views and created_at descending', () => {
// Save templates with different views to ensure predictable ordering
const template1 = createTemplateWorkflow({ id: 1, name: 'First', totalViews: 50 });
@@ -448,7 +365,7 @@ describe('TemplateRepository Integration Tests', () => {
const saved = repository.getTemplate(1);
expect(saved).toBeTruthy();
expect(saved?.workflow_json).toBeTruthy();
const workflow = JSON.parse(saved!.workflow_json!);
const workflow = JSON.parse(saved!.workflow_json);
expect(workflow.nodes).toHaveLength(detail.workflow.nodes.length);
});
});
@@ -545,104 +462,6 @@ describe('TemplateRepository Integration Tests', () => {
expect(results).toHaveLength(50);
});
});
describe('New pagination count methods', () => {
beforeEach(() => {
// Set up test data
for (let i = 1; i <= 25; i++) {
const template = createTemplateWorkflow({
id: i,
name: `Template ${i}`,
description: i <= 10 ? 'webhook automation' : 'data processing'
});
const detail = createTemplateDetail({
id: i,
workflow: {
nodes: i <= 15 ? [
{ id: 'node1', type: 'n8n-nodes-base.webhook', name: 'Webhook', position: [0, 0], parameters: {}, typeVersion: 1 }
] : [
{ id: 'node1', type: 'n8n-nodes-base.httpRequest', name: 'HTTP', position: [0, 0], parameters: {}, typeVersion: 1 }
],
connections: {},
settings: {}
}
});
repository.saveTemplate(template, detail);
}
});
describe('getNodeTemplatesCount', () => {
it('should return correct count for node type searches', () => {
const webhookCount = repository.getNodeTemplatesCount(['n8n-nodes-base.webhook']);
expect(webhookCount).toBe(15);
const httpCount = repository.getNodeTemplatesCount(['n8n-nodes-base.httpRequest']);
expect(httpCount).toBe(10);
const bothCount = repository.getNodeTemplatesCount([
'n8n-nodes-base.webhook',
'n8n-nodes-base.httpRequest'
]);
expect(bothCount).toBe(25); // OR query, so all templates
});
it('should return 0 for non-existent node types', () => {
const count = repository.getNodeTemplatesCount(['non-existent-node']);
expect(count).toBe(0);
});
});
describe('getSearchCount', () => {
it('should return correct count for search queries', () => {
const webhookSearchCount = repository.getSearchCount('webhook');
expect(webhookSearchCount).toBe(10);
const processingSearchCount = repository.getSearchCount('processing');
expect(processingSearchCount).toBe(15);
const noResultsCount = repository.getSearchCount('nonexistent');
expect(noResultsCount).toBe(0);
});
});
describe('getTaskTemplatesCount', () => {
it('should return correct count for task-based searches', () => {
const webhookTaskCount = repository.getTaskTemplatesCount('webhook_processing');
expect(webhookTaskCount).toBeGreaterThan(0);
const unknownTaskCount = repository.getTaskTemplatesCount('unknown_task');
expect(unknownTaskCount).toBe(0);
});
});
describe('getTemplateCount', () => {
it('should return total template count', () => {
const totalCount = repository.getTemplateCount();
expect(totalCount).toBe(25);
});
it('should return 0 for empty database', () => {
repository.clearTemplates();
const count = repository.getTemplateCount();
expect(count).toBe(0);
});
});
describe('getTemplatesForTask with pagination', () => {
it('should support pagination for task-based searches', () => {
const page1 = repository.getTemplatesForTask('webhook_processing', 5, 0);
const page2 = repository.getTemplatesForTask('webhook_processing', 5, 5);
expect(page1).toHaveLength(5);
expect(page2).toHaveLength(5);
// Should be different results
const page1Ids = page1.map(t => t.id);
const page2Ids = page2.map(t => t.id);
expect(page1Ids.filter(id => page2Ids.includes(id))).toHaveLength(0);
});
});
});
});
// Helper functions

View File

@@ -1,579 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { TemplateService } from '../../../src/templates/template-service';
import { TemplateRepository } from '../../../src/templates/template-repository';
import { MetadataGenerator } from '../../../src/templates/metadata-generator';
import { BatchProcessor } from '../../../src/templates/batch-processor';
import { DatabaseAdapter, createDatabaseAdapter } from '../../../src/database/database-adapter';
import { tmpdir } from 'os';
import * as path from 'path';
import { unlinkSync, existsSync, readFileSync } from 'fs';
// Mock logger
vi.mock('../../../src/utils/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn()
}
}));
// Mock template sanitizer
vi.mock('../../../src/utils/template-sanitizer', () => {
class MockTemplateSanitizer {
sanitizeWorkflow = vi.fn((workflow) => ({ sanitized: workflow, wasModified: false }));
detectTokens = vi.fn(() => []);
}
return {
TemplateSanitizer: MockTemplateSanitizer
};
});
// Mock OpenAI for MetadataGenerator and BatchProcessor
vi.mock('openai', () => {
const mockClient = {
chat: {
completions: {
create: vi.fn()
}
},
files: {
create: vi.fn(),
content: vi.fn(),
del: vi.fn()
},
batches: {
create: vi.fn(),
retrieve: vi.fn()
}
};
return {
default: vi.fn().mockImplementation(() => mockClient)
};
});
describe('Template Metadata Operations - Integration Tests', () => {
let adapter: DatabaseAdapter;
let repository: TemplateRepository;
let service: TemplateService;
let dbPath: string;
beforeEach(async () => {
// Create temporary database
dbPath = path.join(tmpdir(), `test-metadata-${Date.now()}.db`);
adapter = await createDatabaseAdapter(dbPath);
// Initialize database schema
const schemaPath = path.join(__dirname, '../../../src/database/schema.sql');
const schema = readFileSync(schemaPath, 'utf8');
adapter.exec(schema);
// Initialize repository and service
repository = new TemplateRepository(adapter);
service = new TemplateService(adapter);
// Create test templates
await createTestTemplates();
});
afterEach(() => {
if (adapter) {
adapter.close();
}
if (existsSync(dbPath)) {
unlinkSync(dbPath);
}
vi.clearAllMocks();
});
async function createTestTemplates() {
// Create test templates with metadata
const templates = [
{
workflow: {
id: 1,
name: 'Simple Webhook Slack',
description: 'Basic webhook to Slack automation',
user: { id: 1, name: 'Test User', username: 'test', verified: true },
nodes: [
{ id: 1, name: 'n8n-nodes-base.webhook', icon: 'fa:webhook' },
{ id: 2, name: 'n8n-nodes-base.slack', icon: 'fa:slack' }
],
totalViews: 150,
createdAt: '2024-01-01T00:00:00Z'
},
detail: {
id: 1,
name: 'Simple Webhook Slack',
description: 'Basic webhook to Slack automation',
views: 150,
createdAt: '2024-01-01T00:00:00Z',
workflow: {
nodes: [
{ type: 'n8n-nodes-base.webhook', name: 'Webhook', id: '1', position: [0, 0], parameters: {}, typeVersion: 1 },
{ type: 'n8n-nodes-base.slack', name: 'Slack', id: '2', position: [100, 0], parameters: {}, typeVersion: 1 }
],
connections: { '1': { main: [[{ node: '2', type: 'main', index: 0 }]] } },
settings: {}
}
},
categories: ['automation', 'communication'],
metadata: {
categories: ['automation', 'communication'],
complexity: 'simple' as const,
use_cases: ['Webhook processing', 'Slack notifications'],
estimated_setup_minutes: 15,
required_services: ['Slack API'],
key_features: ['Real-time notifications', 'Easy setup'],
target_audience: ['developers', 'marketers']
}
},
{
workflow: {
id: 2,
name: 'Complex AI Data Pipeline',
description: 'Advanced data processing with AI analysis',
user: { id: 2, name: 'AI Expert', username: 'aiexpert', verified: true },
nodes: [
{ id: 1, name: 'n8n-nodes-base.webhook', icon: 'fa:webhook' },
{ id: 2, name: '@n8n/n8n-nodes-langchain.openAi', icon: 'fa:brain' },
{ id: 3, name: 'n8n-nodes-base.postgres', icon: 'fa:database' },
{ id: 4, name: 'n8n-nodes-base.googleSheets', icon: 'fa:sheet' }
],
totalViews: 450,
createdAt: '2024-01-15T00:00:00Z'
},
detail: {
id: 2,
name: 'Complex AI Data Pipeline',
description: 'Advanced data processing with AI analysis',
views: 450,
createdAt: '2024-01-15T00:00:00Z',
workflow: {
nodes: [
{ type: 'n8n-nodes-base.webhook', name: 'Webhook', id: '1', position: [0, 0], parameters: {}, typeVersion: 1 },
{ type: '@n8n/n8n-nodes-langchain.openAi', name: 'OpenAI', id: '2', position: [100, 0], parameters: {}, typeVersion: 1 },
{ type: 'n8n-nodes-base.postgres', name: 'Postgres', id: '3', position: [200, 0], parameters: {}, typeVersion: 1 },
{ type: 'n8n-nodes-base.googleSheets', name: 'Google Sheets', id: '4', position: [300, 0], parameters: {}, typeVersion: 1 }
],
connections: {
'1': { main: [[{ node: '2', type: 'main', index: 0 }]] },
'2': { main: [[{ node: '3', type: 'main', index: 0 }]] },
'3': { main: [[{ node: '4', type: 'main', index: 0 }]] }
},
settings: {}
}
},
categories: ['ai', 'data_processing'],
metadata: {
categories: ['ai', 'data_processing', 'automation'],
complexity: 'complex' as const,
use_cases: ['Data analysis', 'AI processing', 'Report generation'],
estimated_setup_minutes: 120,
required_services: ['OpenAI API', 'PostgreSQL', 'Google Sheets API'],
key_features: ['AI analysis', 'Database integration', 'Automated reports'],
target_audience: ['developers', 'analysts']
}
},
{
workflow: {
id: 3,
name: 'Medium Email Automation',
description: 'Email automation with moderate complexity',
user: { id: 3, name: 'Marketing User', username: 'marketing', verified: false },
nodes: [
{ id: 1, name: 'n8n-nodes-base.cron', icon: 'fa:clock' },
{ id: 2, name: 'n8n-nodes-base.gmail', icon: 'fa:mail' },
{ id: 3, name: 'n8n-nodes-base.googleSheets', icon: 'fa:sheet' }
],
totalViews: 200,
createdAt: '2024-02-01T00:00:00Z'
},
detail: {
id: 3,
name: 'Medium Email Automation',
description: 'Email automation with moderate complexity',
views: 200,
createdAt: '2024-02-01T00:00:00Z',
workflow: {
nodes: [
{ type: 'n8n-nodes-base.cron', name: 'Cron', id: '1', position: [0, 0], parameters: {}, typeVersion: 1 },
{ type: 'n8n-nodes-base.gmail', name: 'Gmail', id: '2', position: [100, 0], parameters: {}, typeVersion: 1 },
{ type: 'n8n-nodes-base.googleSheets', name: 'Google Sheets', id: '3', position: [200, 0], parameters: {}, typeVersion: 1 }
],
connections: {
'1': { main: [[{ node: '2', type: 'main', index: 0 }]] },
'2': { main: [[{ node: '3', type: 'main', index: 0 }]] }
},
settings: {}
}
},
categories: ['email_automation', 'scheduling'],
metadata: {
categories: ['email_automation', 'scheduling'],
complexity: 'medium' as const,
use_cases: ['Email campaigns', 'Scheduled reports'],
estimated_setup_minutes: 45,
required_services: ['Gmail API', 'Google Sheets API'],
key_features: ['Scheduled execution', 'Email automation'],
target_audience: ['marketers']
}
}
];
// Save templates
for (const template of templates) {
repository.saveTemplate(template.workflow, template.detail, template.categories);
repository.updateTemplateMetadata(template.workflow.id, template.metadata);
}
}
describe('Repository Metadata Operations', () => {
it('should update template metadata successfully', () => {
const newMetadata = {
categories: ['test', 'updated'],
complexity: 'simple' as const,
use_cases: ['Testing'],
estimated_setup_minutes: 10,
required_services: [],
key_features: ['Test feature'],
target_audience: ['testers']
};
repository.updateTemplateMetadata(1, newMetadata);
// Verify metadata was updated
const templates = repository.searchTemplatesByMetadata({
category: 'test'}, 10, 0);
expect(templates).toHaveLength(1);
expect(templates[0].id).toBe(1);
});
it('should batch update metadata for multiple templates', () => {
const metadataMap = new Map([
[1, {
categories: ['batch_test'],
complexity: 'simple' as const,
use_cases: ['Batch testing'],
estimated_setup_minutes: 20,
required_services: [],
key_features: ['Batch update'],
target_audience: ['developers']
}],
[2, {
categories: ['batch_test'],
complexity: 'complex' as const,
use_cases: ['Complex batch testing'],
estimated_setup_minutes: 60,
required_services: ['OpenAI'],
key_features: ['Advanced batch'],
target_audience: ['developers']
}]
]);
repository.batchUpdateMetadata(metadataMap);
// Verify both templates were updated
const templates = repository.searchTemplatesByMetadata({
category: 'batch_test'}, 10, 0);
expect(templates).toHaveLength(2);
expect(templates.map(t => t.id).sort()).toEqual([1, 2]);
});
it('should search templates by category', () => {
const templates = repository.searchTemplatesByMetadata({
category: 'automation'}, 10, 0);
expect(templates.length).toBeGreaterThan(0);
expect(templates[0]).toHaveProperty('id');
expect(templates[0]).toHaveProperty('name');
});
it('should search templates by complexity', () => {
const simpleTemplates = repository.searchTemplatesByMetadata({
complexity: 'simple'}, 10, 0);
const complexTemplates = repository.searchTemplatesByMetadata({
complexity: 'complex'}, 10, 0);
expect(simpleTemplates).toHaveLength(1);
expect(complexTemplates).toHaveLength(1);
expect(simpleTemplates[0].id).toBe(1);
expect(complexTemplates[0].id).toBe(2);
});
it('should search templates by setup time', () => {
const quickTemplates = repository.searchTemplatesByMetadata({
maxSetupMinutes: 30}, 10, 0);
const longTemplates = repository.searchTemplatesByMetadata({
minSetupMinutes: 60}, 10, 0);
expect(quickTemplates).toHaveLength(1); // Only 15 min template (45 min > 30)
expect(longTemplates).toHaveLength(1); // 120 min template
});
it('should search templates by required service', () => {
const slackTemplates = repository.searchTemplatesByMetadata({
requiredService: 'slack'}, 10, 0);
const openaiTemplates = repository.searchTemplatesByMetadata({
requiredService: 'OpenAI'}, 10, 0);
expect(slackTemplates).toHaveLength(1);
expect(openaiTemplates).toHaveLength(1);
});
it('should search templates by target audience', () => {
const developerTemplates = repository.searchTemplatesByMetadata({
targetAudience: 'developers'}, 10, 0);
const marketerTemplates = repository.searchTemplatesByMetadata({
targetAudience: 'marketers'}, 10, 0);
expect(developerTemplates).toHaveLength(2);
expect(marketerTemplates).toHaveLength(2);
});
it('should handle combined filters correctly', () => {
const filteredTemplates = repository.searchTemplatesByMetadata({
complexity: 'medium',
targetAudience: 'marketers',
maxSetupMinutes: 60}, 10, 0);
expect(filteredTemplates).toHaveLength(1);
expect(filteredTemplates[0].id).toBe(3);
});
it('should return correct counts for metadata searches', () => {
const automationCount = repository.getSearchTemplatesByMetadataCount({
category: 'automation'
});
const complexCount = repository.getSearchTemplatesByMetadataCount({
complexity: 'complex'
});
expect(automationCount).toBeGreaterThan(0);
expect(complexCount).toBe(1);
});
it('should get unique categories', () => {
const categories = repository.getUniqueCategories();
expect(categories).toContain('automation');
expect(categories).toContain('communication');
expect(categories).toContain('ai');
expect(categories).toContain('data_processing');
expect(categories).toContain('email_automation');
expect(categories).toContain('scheduling');
});
it('should get unique target audiences', () => {
const audiences = repository.getUniqueTargetAudiences();
expect(audiences).toContain('developers');
expect(audiences).toContain('marketers');
expect(audiences).toContain('analysts');
});
it('should get templates by category', () => {
const aiTemplates = repository.getTemplatesByCategory('ai');
// Both template 2 has 'ai', and template 1 has 'automation' which contains 'ai' as substring
// due to LIKE '%ai%' matching
expect(aiTemplates).toHaveLength(2);
// Template 2 should be first due to higher view count (450 vs 150)
expect(aiTemplates[0].id).toBe(2);
});
it('should get templates by complexity', () => {
const simpleTemplates = repository.getTemplatesByComplexity('simple');
expect(simpleTemplates).toHaveLength(1);
expect(simpleTemplates[0].id).toBe(1);
});
it('should get templates without metadata', () => {
// Create a template without metadata
const workflow = {
id: 999,
name: 'No Metadata Template',
description: 'Template without metadata',
user: { id: 999, name: 'Test', username: 'test', verified: true },
nodes: [{ id: 1, name: 'n8n-nodes-base.webhook', icon: 'fa:webhook' }],
totalViews: 50, // Must be > 10 to not be filtered out
createdAt: '2024-03-01T00:00:00Z'
};
const detail = {
id: 999,
name: 'No Metadata Template',
description: 'Template without metadata',
views: 50, // Must be > 10 to not be filtered out
createdAt: '2024-03-01T00:00:00Z',
workflow: {
nodes: [{ type: 'n8n-nodes-base.webhook', name: 'Webhook', id: '1', position: [0, 0], parameters: {}, typeVersion: 1 }],
connections: {},
settings: {}
}
};
repository.saveTemplate(workflow, detail, []);
// Don't update metadata for this template, so it remains without metadata
const templatesWithoutMetadata = repository.getTemplatesWithoutMetadata();
expect(templatesWithoutMetadata.some(t => t.workflow_id === 999)).toBe(true);
});
it('should get outdated metadata templates', () => {
// This test would require manipulating timestamps,
// for now just verify the method doesn't throw
const outdatedTemplates = repository.getTemplatesWithOutdatedMetadata(30);
expect(Array.isArray(outdatedTemplates)).toBe(true);
});
it('should get metadata statistics', () => {
const stats = repository.getMetadataStats();
expect(stats).toHaveProperty('withMetadata');
expect(stats).toHaveProperty('total');
expect(stats).toHaveProperty('withoutMetadata');
expect(stats).toHaveProperty('outdated');
expect(stats.withMetadata).toBeGreaterThan(0);
expect(stats.total).toBeGreaterThan(0);
});
});
describe('Service Layer Integration', () => {
it('should search templates with metadata through service', async () => {
const results = await service.searchTemplatesByMetadata({
complexity: 'simple'}, 10, 0);
expect(results).toHaveProperty('items');
expect(results).toHaveProperty('total');
expect(results).toHaveProperty('hasMore');
expect(results.items.length).toBeGreaterThan(0);
expect(results.items[0]).toHaveProperty('metadata');
});
it('should handle pagination correctly in metadata search', async () => {
const page1 = await service.searchTemplatesByMetadata(
{}, // empty filters
1, // limit
0 // offset
);
const page2 = await service.searchTemplatesByMetadata(
{}, // empty filters
1, // limit
1 // offset
);
expect(page1.items).toHaveLength(1);
expect(page2.items).toHaveLength(1);
expect(page1.items[0].id).not.toBe(page2.items[0].id);
});
it('should return templates with metadata information', async () => {
const results = await service.searchTemplatesByMetadata({
category: 'automation'}, 10, 0);
expect(results.items.length).toBeGreaterThan(0);
const template = results.items[0];
expect(template).toHaveProperty('metadata');
expect(template.metadata).toHaveProperty('categories');
expect(template.metadata).toHaveProperty('complexity');
expect(template.metadata).toHaveProperty('estimated_setup_minutes');
});
});
describe('Security and Error Handling', () => {
it('should handle malicious input safely in metadata search', () => {
const maliciousInputs = [
{ category: "'; DROP TABLE templates; --" },
{ requiredService: "'; UNION SELECT * FROM sqlite_master; --" },
{ targetAudience: "administrators'; DELETE FROM templates WHERE '1'='1" }
];
maliciousInputs.forEach(input => {
expect(() => {
repository.searchTemplatesByMetadata({
...input}, 10, 0);
}).not.toThrow();
});
});
it('should handle invalid metadata gracefully', () => {
const invalidMetadata = {
categories: null,
complexity: 'invalid_complexity',
use_cases: 'not_an_array',
estimated_setup_minutes: 'not_a_number',
required_services: undefined,
key_features: {},
target_audience: 42
};
expect(() => {
repository.updateTemplateMetadata(1, invalidMetadata);
}).not.toThrow();
});
it('should handle empty search results gracefully', () => {
const results = repository.searchTemplatesByMetadata({
category: 'nonexistent_category'}, 10, 0);
expect(results).toHaveLength(0);
});
it('should handle edge case parameters', () => {
// Test extreme values
const results = repository.searchTemplatesByMetadata({
maxSetupMinutes: 0,
minSetupMinutes: 999999
}, 0, -1); // offset -1 to test edge case
expect(Array.isArray(results)).toBe(true);
});
});
describe('Performance and Scalability', () => {
it('should handle large result sets efficiently', () => {
// Test with maximum limit
const startTime = Date.now();
const results = repository.searchTemplatesByMetadata({}, 100, 0);
const endTime = Date.now();
expect(endTime - startTime).toBeLessThan(1000); // Should complete within 1 second
expect(Array.isArray(results)).toBe(true);
});
it('should handle concurrent metadata updates', () => {
const updates: any[] = [];
for (let i = 0; i < 10; i++) {
updates.push(() => {
repository.updateTemplateMetadata(1, {
categories: [`concurrent_test_${i}`],
complexity: 'simple' as const,
use_cases: ['Testing'],
estimated_setup_minutes: 10,
required_services: [],
key_features: ['Concurrent'],
target_audience: ['developers']
});
});
}
// Execute all updates
expect(() => {
updates.forEach(update => update());
}).not.toThrow();
});
});
});

View File

@@ -48,15 +48,8 @@ if (!testConfig.logging.debug) {
// Set up performance monitoring if enabled
if (testConfig.performance) {
// Use a high-resolution timer that maintains timing precision
let startTime = process.hrtime.bigint();
global.performance = global.performance || {
now: () => {
// Convert nanoseconds to milliseconds with high precision
const currentTime = process.hrtime.bigint();
return Number(currentTime - startTime) / 1000000; // Convert nanoseconds to milliseconds
},
now: () => Date.now(),
mark: vi.fn(),
measure: vi.fn(),
getEntriesByName: vi.fn(() => []),

View File

@@ -173,7 +173,6 @@ describe('TemplateRepository - Core Functionality', () => {
call => call[0].includes('INSERT OR REPLACE INTO templates')
)?.[0] || '');
// The implementation now uses gzip compression, so we just verify the call happened
expect(stmt?.run).toHaveBeenCalledWith(
123, // id
123, // workflow_id
@@ -183,7 +182,14 @@ describe('TemplateRepository - Core Functionality', () => {
'johndoe',
1, // verified
JSON.stringify(['n8n-nodes-base.httpRequest', 'n8n-nodes-base.slack']),
expect.any(String), // compressed workflow JSON
JSON.stringify({
nodes: [
{ type: 'n8n-nodes-base.httpRequest', name: 'HTTP Request', id: '1', position: [0, 0], parameters: {}, typeVersion: 1 },
{ type: 'n8n-nodes-base.slack', name: 'Slack', id: '2', position: [100, 0], parameters: {}, typeVersion: 1 }
],
connections: {},
settings: {}
}),
JSON.stringify(['automation', 'integration']),
1000, // views
'2024-01-01T00:00:00Z',
@@ -310,7 +316,7 @@ describe('TemplateRepository - Core Functionality', () => {
const results = repository.getTemplatesByNodes(['n8n-nodes-base.httpRequest'], 5);
expect(stmt.all).toHaveBeenCalledWith('%"n8n-nodes-base.httpRequest"%', 5, 0);
expect(stmt.all).toHaveBeenCalledWith('%"n8n-nodes-base.httpRequest"%', 5);
expect(results).toEqual(mockTemplates);
});
});
@@ -385,102 +391,6 @@ describe('TemplateRepository - Core Functionality', () => {
expect(stats.topUsedNodes).toContainEqual({ node: 'n8n-nodes-base.httpRequest', count: 2 });
});
});
describe('pagination count methods', () => {
it('should get node templates count', () => {
mockAdapter._setMockData('node_templates_count', 15);
const stmt = new MockPreparedStatement('', new Map());
stmt.get = vi.fn(() => ({ count: 15 }));
mockAdapter.prepare = vi.fn(() => stmt);
const count = repository.getNodeTemplatesCount(['n8n-nodes-base.webhook']);
expect(count).toBe(15);
expect(stmt.get).toHaveBeenCalledWith('%"n8n-nodes-base.webhook"%');
});
it('should get search count', () => {
const stmt = new MockPreparedStatement('', new Map());
stmt.get = vi.fn(() => ({ count: 8 }));
mockAdapter.prepare = vi.fn(() => stmt);
const count = repository.getSearchCount('webhook');
expect(count).toBe(8);
});
it('should get task templates count', () => {
const stmt = new MockPreparedStatement('', new Map());
stmt.get = vi.fn(() => ({ count: 12 }));
mockAdapter.prepare = vi.fn(() => stmt);
const count = repository.getTaskTemplatesCount('ai_automation');
expect(count).toBe(12);
});
it('should handle pagination in getAllTemplates', () => {
const mockTemplates = [
{ id: 1, name: 'Template 1' },
{ id: 2, name: 'Template 2' }
];
const stmt = new MockPreparedStatement('', new Map());
stmt.all = vi.fn(() => mockTemplates);
mockAdapter.prepare = vi.fn(() => stmt);
const results = repository.getAllTemplates(10, 5, 'name');
expect(results).toEqual(mockTemplates);
expect(stmt.all).toHaveBeenCalledWith(10, 5);
});
it('should handle pagination in getTemplatesByNodes', () => {
const mockTemplates = [
{ id: 1, nodes_used: '["n8n-nodes-base.webhook"]' }
];
const stmt = new MockPreparedStatement('', new Map());
stmt.all = vi.fn(() => mockTemplates);
mockAdapter.prepare = vi.fn(() => stmt);
const results = repository.getTemplatesByNodes(['n8n-nodes-base.webhook'], 5, 10);
expect(results).toEqual(mockTemplates);
expect(stmt.all).toHaveBeenCalledWith('%"n8n-nodes-base.webhook"%', 5, 10);
});
it('should handle pagination in searchTemplates', () => {
const mockTemplates = [
{ id: 1, name: 'Search Result 1' }
];
mockAdapter._setMockData('fts_results', mockTemplates);
const stmt = new MockPreparedStatement('', new Map());
stmt.all = vi.fn(() => mockTemplates);
mockAdapter.prepare = vi.fn(() => stmt);
const results = repository.searchTemplates('webhook', 20, 40);
expect(results).toEqual(mockTemplates);
});
it('should handle pagination in getTemplatesForTask', () => {
const mockTemplates = [
{ id: 1, categories: '["ai"]' }
];
const stmt = new MockPreparedStatement('', new Map());
stmt.all = vi.fn(() => mockTemplates);
mockAdapter.prepare = vi.fn(() => stmt);
const results = repository.getTemplatesForTask('ai_automation', 15, 30);
expect(results).toEqual(mockTemplates);
});
});
describe('maintenance operations', () => {
it('should clear all templates', () => {

View File

@@ -371,7 +371,7 @@ describe('Parameter Validation', () => {
templateId: 123
});
expect(mockGetTemplate).toHaveBeenCalledWith(123, 'full');
expect(mockGetTemplate).toHaveBeenCalledWith(123);
});
it('should convert string templateId to number', async () => {
@@ -381,7 +381,7 @@ describe('Parameter Validation', () => {
templateId: '123'
});
expect(mockGetTemplate).toHaveBeenCalledWith(123, 'full');
expect(mockGetTemplate).toHaveBeenCalledWith(123);
});
});
});

View File

@@ -254,7 +254,7 @@ describe('n8nDocumentationToolsFinal', () => {
discovery: ['list_nodes', 'search_nodes', 'list_ai_tools'],
configuration: ['get_node_info', 'get_node_essentials', 'get_node_documentation'],
validation: ['validate_node_operation', 'validate_workflow', 'validate_node_minimal'],
templates: ['list_tasks', 'get_node_for_task', 'search_templates', 'list_templates', 'get_template', 'list_node_templates'],
templates: ['list_tasks', 'get_node_for_task', 'search_templates'],
documentation: ['tools_documentation']
};
@@ -317,184 +317,4 @@ describe('n8nDocumentationToolsFinal', () => {
});
});
});
describe('New Template Tools', () => {
describe('list_templates', () => {
const tool = n8nDocumentationToolsFinal.find(t => t.name === 'list_templates');
it('should exist and be properly defined', () => {
expect(tool).toBeDefined();
expect(tool?.description).toContain('minimal data');
});
it('should have correct parameters', () => {
expect(tool?.inputSchema.properties).toHaveProperty('limit');
expect(tool?.inputSchema.properties).toHaveProperty('offset');
expect(tool?.inputSchema.properties).toHaveProperty('sortBy');
const limitParam = tool?.inputSchema.properties.limit;
expect(limitParam.type).toBe('number');
expect(limitParam.minimum).toBe(1);
expect(limitParam.maximum).toBe(100);
const offsetParam = tool?.inputSchema.properties.offset;
expect(offsetParam.type).toBe('number');
expect(offsetParam.minimum).toBe(0);
const sortByParam = tool?.inputSchema.properties.sortBy;
expect(sortByParam.enum).toEqual(['views', 'created_at', 'name']);
});
it('should have no required parameters', () => {
expect(tool?.inputSchema.required).toBeUndefined();
});
});
describe('get_template (enhanced)', () => {
const tool = n8nDocumentationToolsFinal.find(t => t.name === 'get_template');
it('should exist and support mode parameter', () => {
expect(tool).toBeDefined();
expect(tool?.description).toContain('mode');
});
it('should have mode parameter with correct values', () => {
expect(tool?.inputSchema.properties).toHaveProperty('mode');
const modeParam = tool?.inputSchema.properties.mode;
expect(modeParam.enum).toEqual(['nodes_only', 'structure', 'full']);
expect(modeParam.default).toBe('full');
});
it('should require templateId parameter', () => {
expect(tool?.inputSchema.required).toContain('templateId');
});
});
describe('search_templates_by_metadata', () => {
const tool = n8nDocumentationToolsFinal.find(t => t.name === 'search_templates_by_metadata');
it('should exist in the tools array', () => {
expect(tool).toBeDefined();
expect(tool?.name).toBe('search_templates_by_metadata');
});
it('should have proper description', () => {
expect(tool?.description).toContain('Search templates by AI-generated metadata');
expect(tool?.description).toContain('category');
expect(tool?.description).toContain('complexity');
});
it('should have correct input schema structure', () => {
expect(tool?.inputSchema.type).toBe('object');
expect(tool?.inputSchema.properties).toBeDefined();
expect(tool?.inputSchema.required).toBeUndefined(); // All parameters are optional
});
it('should have category parameter with proper schema', () => {
const categoryProp = tool?.inputSchema.properties?.category;
expect(categoryProp).toBeDefined();
expect(categoryProp.type).toBe('string');
expect(categoryProp.description).toContain('category');
});
it('should have complexity parameter with enum values', () => {
const complexityProp = tool?.inputSchema.properties?.complexity;
expect(complexityProp).toBeDefined();
expect(complexityProp.enum).toEqual(['simple', 'medium', 'complex']);
expect(complexityProp.description).toContain('complexity');
});
it('should have time-based parameters with numeric constraints', () => {
const maxTimeProp = tool?.inputSchema.properties?.maxSetupMinutes;
const minTimeProp = tool?.inputSchema.properties?.minSetupMinutes;
expect(maxTimeProp).toBeDefined();
expect(maxTimeProp.type).toBe('number');
expect(maxTimeProp.maximum).toBe(480);
expect(maxTimeProp.minimum).toBe(5);
expect(minTimeProp).toBeDefined();
expect(minTimeProp.type).toBe('number');
expect(minTimeProp.maximum).toBe(480);
expect(minTimeProp.minimum).toBe(5);
});
it('should have service and audience parameters', () => {
const serviceProp = tool?.inputSchema.properties?.requiredService;
const audienceProp = tool?.inputSchema.properties?.targetAudience;
expect(serviceProp).toBeDefined();
expect(serviceProp.type).toBe('string');
expect(serviceProp.description).toContain('service');
expect(audienceProp).toBeDefined();
expect(audienceProp.type).toBe('string');
expect(audienceProp.description).toContain('audience');
});
it('should have pagination parameters', () => {
const limitProp = tool?.inputSchema.properties?.limit;
const offsetProp = tool?.inputSchema.properties?.offset;
expect(limitProp).toBeDefined();
expect(limitProp.type).toBe('number');
expect(limitProp.default).toBe(20);
expect(limitProp.maximum).toBe(100);
expect(limitProp.minimum).toBe(1);
expect(offsetProp).toBeDefined();
expect(offsetProp.type).toBe('number');
expect(offsetProp.default).toBe(0);
expect(offsetProp.minimum).toBe(0);
});
it('should include all expected properties', () => {
const properties = Object.keys(tool?.inputSchema.properties || {});
const expectedProperties = [
'category',
'complexity',
'maxSetupMinutes',
'minSetupMinutes',
'requiredService',
'targetAudience',
'limit',
'offset'
];
expectedProperties.forEach(prop => {
expect(properties).toContain(prop);
});
});
it('should have appropriate additionalProperties setting', () => {
expect(tool?.inputSchema.additionalProperties).toBe(false);
});
});
describe('Enhanced pagination support', () => {
const paginatedTools = ['list_node_templates', 'search_templates', 'get_templates_for_task', 'search_templates_by_metadata'];
paginatedTools.forEach(toolName => {
describe(toolName, () => {
const tool = n8nDocumentationToolsFinal.find(t => t.name === toolName);
it('should support limit parameter', () => {
expect(tool?.inputSchema.properties).toHaveProperty('limit');
const limitParam = tool?.inputSchema.properties.limit;
expect(limitParam.type).toBe('number');
expect(limitParam.minimum).toBeGreaterThanOrEqual(1);
expect(limitParam.maximum).toBeGreaterThanOrEqual(50);
});
it('should support offset parameter', () => {
expect(tool?.inputSchema.properties).toHaveProperty('offset');
const offsetParam = tool?.inputSchema.properties.offset;
expect(offsetParam.type).toBe('number');
expect(offsetParam.minimum).toBe(0);
});
});
});
});
});
});

View File

@@ -1,689 +0,0 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { TemplateService, PaginatedResponse, TemplateInfo, TemplateMinimal } from '../../../src/templates/template-service';
import { TemplateRepository, StoredTemplate } from '../../../src/templates/template-repository';
import { DatabaseAdapter } from '../../../src/database/database-adapter';
// Mock the logger
vi.mock('../../../src/utils/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn()
}
}));
// Mock the template repository
vi.mock('../../../src/templates/template-repository');
// Mock template fetcher - only imported when needed
vi.mock('../../../src/templates/template-fetcher', () => ({
TemplateFetcher: vi.fn().mockImplementation(() => ({
fetchTemplates: vi.fn(),
fetchAllTemplateDetails: vi.fn()
}))
}));
describe('TemplateService', () => {
let service: TemplateService;
let mockDb: DatabaseAdapter;
let mockRepository: TemplateRepository;
const createMockTemplate = (id: number, overrides: any = {}): StoredTemplate => ({
id,
workflow_id: id,
name: overrides.name || `Template ${id}`,
description: overrides.description || `Description for template ${id}`,
author_name: overrides.author_name || 'Test Author',
author_username: overrides.author_username || 'testuser',
author_verified: overrides.author_verified !== undefined ? overrides.author_verified : 1,
nodes_used: JSON.stringify(overrides.nodes_used || ['n8n-nodes-base.webhook']),
workflow_json: JSON.stringify(overrides.workflow || {
nodes: [
{
id: 'node1',
type: 'n8n-nodes-base.webhook',
name: 'Webhook',
position: [100, 100],
parameters: {}
}
],
connections: {},
settings: {}
}),
categories: JSON.stringify(overrides.categories || ['automation']),
views: overrides.views || 100,
created_at: overrides.created_at || '2024-01-01T00:00:00Z',
updated_at: overrides.updated_at || '2024-01-01T00:00:00Z',
url: overrides.url || `https://n8n.io/workflows/${id}`,
scraped_at: '2024-01-01T00:00:00Z',
metadata_json: overrides.metadata_json || null,
metadata_generated_at: overrides.metadata_generated_at || null
});
beforeEach(() => {
vi.clearAllMocks();
mockDb = {} as DatabaseAdapter;
// Create mock repository with all methods
mockRepository = {
getTemplatesByNodes: vi.fn(),
getNodeTemplatesCount: vi.fn(),
getTemplate: vi.fn(),
searchTemplates: vi.fn(),
getSearchCount: vi.fn(),
getTemplatesForTask: vi.fn(),
getTaskTemplatesCount: vi.fn(),
getAllTemplates: vi.fn(),
getTemplateCount: vi.fn(),
getTemplateStats: vi.fn(),
getExistingTemplateIds: vi.fn(),
clearTemplates: vi.fn(),
saveTemplate: vi.fn(),
rebuildTemplateFTS: vi.fn(),
searchTemplatesByMetadata: vi.fn(),
getMetadataSearchCount: vi.fn()
} as any;
// Mock the constructor
(TemplateRepository as any).mockImplementation(() => mockRepository);
service = new TemplateService(mockDb);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('listNodeTemplates', () => {
it('should return paginated node templates', async () => {
const mockTemplates = [
createMockTemplate(1, { name: 'Webhook Template' }),
createMockTemplate(2, { name: 'HTTP Template' })
];
mockRepository.getTemplatesByNodes = vi.fn().mockReturnValue(mockTemplates);
mockRepository.getNodeTemplatesCount = vi.fn().mockReturnValue(10);
const result = await service.listNodeTemplates(['n8n-nodes-base.webhook'], 5, 0);
expect(result).toEqual({
items: expect.arrayContaining([
expect.objectContaining({
id: 1,
name: 'Webhook Template',
author: expect.objectContaining({
name: 'Test Author',
username: 'testuser',
verified: true
}),
nodes: ['n8n-nodes-base.webhook'],
views: 100
})
]),
total: 10,
limit: 5,
offset: 0,
hasMore: true
});
expect(mockRepository.getTemplatesByNodes).toHaveBeenCalledWith(['n8n-nodes-base.webhook'], 5, 0);
expect(mockRepository.getNodeTemplatesCount).toHaveBeenCalledWith(['n8n-nodes-base.webhook']);
});
it('should handle pagination correctly', async () => {
mockRepository.getTemplatesByNodes = vi.fn().mockReturnValue([]);
mockRepository.getNodeTemplatesCount = vi.fn().mockReturnValue(25);
const result = await service.listNodeTemplates(['n8n-nodes-base.webhook'], 10, 20);
expect(result.hasMore).toBe(false); // 20 + 10 >= 25
expect(result.offset).toBe(20);
expect(result.limit).toBe(10);
});
it('should use default pagination parameters', async () => {
mockRepository.getTemplatesByNodes = vi.fn().mockReturnValue([]);
mockRepository.getNodeTemplatesCount = vi.fn().mockReturnValue(0);
await service.listNodeTemplates(['n8n-nodes-base.webhook']);
expect(mockRepository.getTemplatesByNodes).toHaveBeenCalledWith(['n8n-nodes-base.webhook'], 10, 0);
});
});
describe('getTemplate', () => {
const mockWorkflow = {
nodes: [
{
id: 'node1',
type: 'n8n-nodes-base.webhook',
name: 'Webhook',
position: [100, 100],
parameters: { path: 'test' }
},
{
id: 'node2',
type: 'n8n-nodes-base.slack',
name: 'Slack',
position: [300, 100],
parameters: { channel: '#general' }
}
],
connections: {
'node1': {
'main': [
[{ 'node': 'node2', 'type': 'main', 'index': 0 }]
]
}
},
settings: { timezone: 'UTC' }
};
it('should return template in nodes_only mode', async () => {
const mockTemplate = createMockTemplate(1, { workflow: mockWorkflow });
mockRepository.getTemplate = vi.fn().mockReturnValue(mockTemplate);
const result = await service.getTemplate(1, 'nodes_only');
expect(result).toEqual({
id: 1,
name: 'Template 1',
nodes: [
{ type: 'n8n-nodes-base.webhook', name: 'Webhook' },
{ type: 'n8n-nodes-base.slack', name: 'Slack' }
]
});
});
it('should return template in structure mode', async () => {
const mockTemplate = createMockTemplate(1, { workflow: mockWorkflow });
mockRepository.getTemplate = vi.fn().mockReturnValue(mockTemplate);
const result = await service.getTemplate(1, 'structure');
expect(result).toEqual({
id: 1,
name: 'Template 1',
nodes: [
{
id: 'node1',
type: 'n8n-nodes-base.webhook',
name: 'Webhook',
position: [100, 100]
},
{
id: 'node2',
type: 'n8n-nodes-base.slack',
name: 'Slack',
position: [300, 100]
}
],
connections: mockWorkflow.connections
});
});
it('should return full template in full mode', async () => {
const mockTemplate = createMockTemplate(1, { workflow: mockWorkflow });
mockRepository.getTemplate = vi.fn().mockReturnValue(mockTemplate);
const result = await service.getTemplate(1, 'full');
expect(result).toEqual(expect.objectContaining({
id: 1,
name: 'Template 1',
description: 'Description for template 1',
author: {
name: 'Test Author',
username: 'testuser',
verified: true
},
nodes: ['n8n-nodes-base.webhook'],
views: 100,
workflow: mockWorkflow
}));
});
it('should return null for non-existent template', async () => {
mockRepository.getTemplate = vi.fn().mockReturnValue(null);
const result = await service.getTemplate(999);
expect(result).toBeNull();
});
it('should handle templates with no workflow nodes', async () => {
const mockTemplate = createMockTemplate(1, { workflow: { connections: {}, settings: {} } });
mockRepository.getTemplate = vi.fn().mockReturnValue(mockTemplate);
const result = await service.getTemplate(1, 'nodes_only');
expect(result.nodes).toEqual([]);
});
});
describe('searchTemplates', () => {
it('should return paginated search results', async () => {
const mockTemplates = [
createMockTemplate(1, { name: 'Webhook Automation' }),
createMockTemplate(2, { name: 'Webhook Processing' })
];
mockRepository.searchTemplates = vi.fn().mockReturnValue(mockTemplates);
mockRepository.getSearchCount = vi.fn().mockReturnValue(15);
const result = await service.searchTemplates('webhook', 10, 5);
expect(result).toEqual({
items: expect.arrayContaining([
expect.objectContaining({ id: 1, name: 'Webhook Automation' }),
expect.objectContaining({ id: 2, name: 'Webhook Processing' })
]),
total: 15,
limit: 10,
offset: 5,
hasMore: false // 5 + 10 >= 15
});
expect(mockRepository.searchTemplates).toHaveBeenCalledWith('webhook', 10, 5);
expect(mockRepository.getSearchCount).toHaveBeenCalledWith('webhook');
});
it('should use default parameters', async () => {
mockRepository.searchTemplates = vi.fn().mockReturnValue([]);
mockRepository.getSearchCount = vi.fn().mockReturnValue(0);
await service.searchTemplates('test');
expect(mockRepository.searchTemplates).toHaveBeenCalledWith('test', 20, 0);
});
});
describe('getTemplatesForTask', () => {
it('should return paginated task templates', async () => {
const mockTemplates = [
createMockTemplate(1, { name: 'AI Workflow' }),
createMockTemplate(2, { name: 'ML Pipeline' })
];
mockRepository.getTemplatesForTask = vi.fn().mockReturnValue(mockTemplates);
mockRepository.getTaskTemplatesCount = vi.fn().mockReturnValue(8);
const result = await service.getTemplatesForTask('ai_automation', 5, 3);
expect(result).toEqual({
items: expect.arrayContaining([
expect.objectContaining({ id: 1, name: 'AI Workflow' }),
expect.objectContaining({ id: 2, name: 'ML Pipeline' })
]),
total: 8,
limit: 5,
offset: 3,
hasMore: false // 3 + 5 >= 8
});
expect(mockRepository.getTemplatesForTask).toHaveBeenCalledWith('ai_automation', 5, 3);
expect(mockRepository.getTaskTemplatesCount).toHaveBeenCalledWith('ai_automation');
});
});
describe('listTemplates', () => {
it('should return paginated minimal template data', async () => {
const mockTemplates = [
createMockTemplate(1, {
name: 'Template A',
nodes_used: ['n8n-nodes-base.webhook', 'n8n-nodes-base.slack'],
views: 200
}),
createMockTemplate(2, {
name: 'Template B',
nodes_used: ['n8n-nodes-base.httpRequest'],
views: 150
})
];
mockRepository.getAllTemplates = vi.fn().mockReturnValue(mockTemplates);
mockRepository.getTemplateCount = vi.fn().mockReturnValue(50);
const result = await service.listTemplates(10, 20, 'views');
expect(result).toEqual({
items: [
{ id: 1, name: 'Template A', description: 'Description for template 1', views: 200, nodeCount: 2 },
{ id: 2, name: 'Template B', description: 'Description for template 2', views: 150, nodeCount: 1 }
],
total: 50,
limit: 10,
offset: 20,
hasMore: true // 20 + 10 < 50
});
expect(mockRepository.getAllTemplates).toHaveBeenCalledWith(10, 20, 'views');
expect(mockRepository.getTemplateCount).toHaveBeenCalled();
});
it('should use default parameters', async () => {
mockRepository.getAllTemplates = vi.fn().mockReturnValue([]);
mockRepository.getTemplateCount = vi.fn().mockReturnValue(0);
await service.listTemplates();
expect(mockRepository.getAllTemplates).toHaveBeenCalledWith(10, 0, 'views');
});
it('should handle different sort orders', async () => {
mockRepository.getAllTemplates = vi.fn().mockReturnValue([]);
mockRepository.getTemplateCount = vi.fn().mockReturnValue(0);
await service.listTemplates(5, 0, 'name');
expect(mockRepository.getAllTemplates).toHaveBeenCalledWith(5, 0, 'name');
});
});
describe('listAvailableTasks', () => {
it('should return list of available tasks', () => {
const tasks = service.listAvailableTasks();
expect(tasks).toEqual([
'ai_automation',
'data_sync',
'webhook_processing',
'email_automation',
'slack_integration',
'data_transformation',
'file_processing',
'scheduling',
'api_integration',
'database_operations'
]);
});
});
describe('getTemplateStats', () => {
it('should return template statistics', async () => {
const mockStats = {
totalTemplates: 100,
averageViews: 250,
topUsedNodes: [
{ node: 'n8n-nodes-base.webhook', count: 45 },
{ node: 'n8n-nodes-base.slack', count: 30 }
]
};
mockRepository.getTemplateStats = vi.fn().mockReturnValue(mockStats);
const result = await service.getTemplateStats();
expect(result).toEqual(mockStats);
expect(mockRepository.getTemplateStats).toHaveBeenCalled();
});
});
describe('fetchAndUpdateTemplates', () => {
it('should handle rebuild mode', async () => {
const mockFetcher = {
fetchTemplates: vi.fn().mockResolvedValue([
{ id: 1, name: 'Template 1' },
{ id: 2, name: 'Template 2' }
]),
fetchAllTemplateDetails: vi.fn().mockResolvedValue(new Map([
[1, { id: 1, workflow: { nodes: [], connections: {}, settings: {} } }],
[2, { id: 2, workflow: { nodes: [], connections: {}, settings: {} } }]
]))
};
// Mock dynamic import
vi.doMock('../../../src/templates/template-fetcher', () => ({
TemplateFetcher: vi.fn(() => mockFetcher)
}));
mockRepository.clearTemplates = vi.fn();
mockRepository.saveTemplate = vi.fn();
mockRepository.rebuildTemplateFTS = vi.fn();
const progressCallback = vi.fn();
await service.fetchAndUpdateTemplates(progressCallback, 'rebuild');
expect(mockRepository.clearTemplates).toHaveBeenCalled();
expect(mockRepository.saveTemplate).toHaveBeenCalledTimes(2);
expect(mockRepository.rebuildTemplateFTS).toHaveBeenCalled();
expect(progressCallback).toHaveBeenCalledWith('Complete', 2, 2);
});
it('should handle update mode with existing templates', async () => {
const mockFetcher = {
fetchTemplates: vi.fn().mockResolvedValue([
{ id: 1, name: 'Template 1' },
{ id: 2, name: 'Template 2' },
{ id: 3, name: 'Template 3' }
]),
fetchAllTemplateDetails: vi.fn().mockResolvedValue(new Map([
[3, { id: 3, workflow: { nodes: [], connections: {}, settings: {} } }]
]))
};
// Mock dynamic import
vi.doMock('../../../src/templates/template-fetcher', () => ({
TemplateFetcher: vi.fn(() => mockFetcher)
}));
mockRepository.getExistingTemplateIds = vi.fn().mockReturnValue(new Set([1, 2]));
mockRepository.saveTemplate = vi.fn();
mockRepository.rebuildTemplateFTS = vi.fn();
const progressCallback = vi.fn();
await service.fetchAndUpdateTemplates(progressCallback, 'update');
expect(mockRepository.getExistingTemplateIds).toHaveBeenCalled();
expect(mockRepository.saveTemplate).toHaveBeenCalledTimes(1); // Only new template
expect(mockRepository.rebuildTemplateFTS).toHaveBeenCalled();
});
it('should handle update mode with no new templates', async () => {
const mockFetcher = {
fetchTemplates: vi.fn().mockResolvedValue([
{ id: 1, name: 'Template 1' },
{ id: 2, name: 'Template 2' }
]),
fetchAllTemplateDetails: vi.fn().mockResolvedValue(new Map())
};
// Mock dynamic import
vi.doMock('../../../src/templates/template-fetcher', () => ({
TemplateFetcher: vi.fn(() => mockFetcher)
}));
mockRepository.getExistingTemplateIds = vi.fn().mockReturnValue(new Set([1, 2]));
mockRepository.saveTemplate = vi.fn();
mockRepository.rebuildTemplateFTS = vi.fn();
const progressCallback = vi.fn();
await service.fetchAndUpdateTemplates(progressCallback, 'update');
expect(mockRepository.saveTemplate).not.toHaveBeenCalled();
expect(mockRepository.rebuildTemplateFTS).not.toHaveBeenCalled();
expect(progressCallback).toHaveBeenCalledWith('No new templates', 0, 0);
});
it('should handle errors during fetch', async () => {
// Mock the import to fail during constructor
const mockFetcher = function() {
throw new Error('Fetch failed');
};
vi.doMock('../../../src/templates/template-fetcher', () => ({
TemplateFetcher: mockFetcher
}));
await expect(service.fetchAndUpdateTemplates()).rejects.toThrow('Fetch failed');
});
});
describe('searchTemplatesByMetadata', () => {
it('should return paginated metadata search results', async () => {
const mockTemplates = [
createMockTemplate(1, {
name: 'AI Workflow',
metadata_json: JSON.stringify({
categories: ['ai', 'automation'],
complexity: 'complex',
estimated_setup_minutes: 60
})
}),
createMockTemplate(2, {
name: 'Simple Webhook',
metadata_json: JSON.stringify({
categories: ['automation'],
complexity: 'simple',
estimated_setup_minutes: 15
})
})
];
mockRepository.searchTemplatesByMetadata = vi.fn().mockReturnValue(mockTemplates);
mockRepository.getMetadataSearchCount = vi.fn().mockReturnValue(12);
const result = await service.searchTemplatesByMetadata({
complexity: 'simple',
maxSetupMinutes: 30
}, 10, 5);
expect(result).toEqual({
items: expect.arrayContaining([
expect.objectContaining({
id: 1,
name: 'AI Workflow',
metadata: {
categories: ['ai', 'automation'],
complexity: 'complex',
estimated_setup_minutes: 60
}
}),
expect.objectContaining({
id: 2,
name: 'Simple Webhook',
metadata: {
categories: ['automation'],
complexity: 'simple',
estimated_setup_minutes: 15
}
})
]),
total: 12,
limit: 10,
offset: 5,
hasMore: false // 5 + 10 >= 12
});
expect(mockRepository.searchTemplatesByMetadata).toHaveBeenCalledWith({
complexity: 'simple',
maxSetupMinutes: 30
}, 10, 5);
expect(mockRepository.getMetadataSearchCount).toHaveBeenCalledWith({
complexity: 'simple',
maxSetupMinutes: 30
});
});
it('should use default pagination parameters', async () => {
mockRepository.searchTemplatesByMetadata = vi.fn().mockReturnValue([]);
mockRepository.getMetadataSearchCount = vi.fn().mockReturnValue(0);
await service.searchTemplatesByMetadata({ category: 'test' });
expect(mockRepository.searchTemplatesByMetadata).toHaveBeenCalledWith({ category: 'test' }, 20, 0);
});
it('should handle templates without metadata gracefully', async () => {
const templatesWithoutMetadata = [
createMockTemplate(1, { metadata_json: null }),
createMockTemplate(2, { metadata_json: undefined }),
createMockTemplate(3, { metadata_json: 'invalid json' })
];
mockRepository.searchTemplatesByMetadata = vi.fn().mockReturnValue(templatesWithoutMetadata);
mockRepository.getMetadataSearchCount = vi.fn().mockReturnValue(3);
const result = await service.searchTemplatesByMetadata({ category: 'test' });
expect(result.items).toHaveLength(3);
result.items.forEach(item => {
expect(item.metadata).toBeUndefined();
});
});
it('should handle malformed metadata JSON', async () => {
const templateWithBadMetadata = createMockTemplate(1, {
metadata_json: '{"invalid": json syntax}'
});
mockRepository.searchTemplatesByMetadata = vi.fn().mockReturnValue([templateWithBadMetadata]);
mockRepository.getMetadataSearchCount = vi.fn().mockReturnValue(1);
const result = await service.searchTemplatesByMetadata({ category: 'test' });
expect(result.items).toHaveLength(1);
expect(result.items[0].metadata).toBeUndefined();
});
});
describe('formatTemplateInfo (private method behavior)', () => {
it('should format template data correctly through public methods', async () => {
const mockTemplate = createMockTemplate(1, {
name: 'Test Template',
description: 'Test Description',
author_name: 'John Doe',
author_username: 'johndoe',
author_verified: 1,
nodes_used: ['n8n-nodes-base.webhook', 'n8n-nodes-base.slack'],
views: 500,
created_at: '2024-01-15T10:30:00Z',
url: 'https://n8n.io/workflows/123'
});
mockRepository.searchTemplates = vi.fn().mockReturnValue([mockTemplate]);
mockRepository.getSearchCount = vi.fn().mockReturnValue(1);
const result = await service.searchTemplates('test');
expect(result.items[0]).toEqual({
id: 1,
name: 'Test Template',
description: 'Test Description',
author: {
name: 'John Doe',
username: 'johndoe',
verified: true
},
nodes: ['n8n-nodes-base.webhook', 'n8n-nodes-base.slack'],
views: 500,
created: '2024-01-15T10:30:00Z',
url: 'https://n8n.io/workflows/123'
});
});
it('should handle unverified authors', async () => {
const mockTemplate = createMockTemplate(1, {
author_verified: 0 // Explicitly set to 0 for unverified
});
// Override the helper to return exactly what we want
const unverifiedTemplate = {
...mockTemplate,
author_verified: 0
};
mockRepository.searchTemplates = vi.fn().mockReturnValue([unverifiedTemplate]);
mockRepository.getSearchCount = vi.fn().mockReturnValue(1);
const result = await service.searchTemplates('test');
expect(result.items[0]?.author?.verified).toBe(false);
});
});
});

View File

@@ -1,568 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import { BatchProcessor, BatchProcessorOptions } from '../../../src/templates/batch-processor';
import { MetadataRequest } from '../../../src/templates/metadata-generator';
// Mock fs operations
vi.mock('fs');
const mockedFs = vi.mocked(fs);
// Mock OpenAI
const mockClient = {
files: {
create: vi.fn(),
content: vi.fn(),
del: vi.fn()
},
batches: {
create: vi.fn(),
retrieve: vi.fn()
}
};
vi.mock('openai', () => {
return {
default: class MockOpenAI {
files = mockClient.files;
batches = mockClient.batches;
constructor(config: any) {
// Mock constructor
}
}
};
});
// Mock MetadataGenerator
const mockGenerator = {
createBatchRequest: vi.fn(),
parseResult: vi.fn()
};
vi.mock('../../../src/templates/metadata-generator', () => {
// Define MockMetadataGenerator inside the factory to avoid hoisting issues
class MockMetadataGenerator {
createBatchRequest = mockGenerator.createBatchRequest;
parseResult = mockGenerator.parseResult;
}
return {
MetadataGenerator: MockMetadataGenerator
};
});
// Mock logger
vi.mock('../../../src/utils/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn()
}
}));
describe('BatchProcessor', () => {
let processor: BatchProcessor;
let options: BatchProcessorOptions;
let mockStream: any;
beforeEach(() => {
vi.clearAllMocks();
options = {
apiKey: 'test-api-key',
model: 'gpt-4o-mini',
batchSize: 3,
outputDir: './test-temp'
};
// Mock stream for file writing
mockStream = {
write: vi.fn(),
end: vi.fn(),
on: vi.fn((event, callback) => {
if (event === 'finish') {
setTimeout(callback, 0);
}
})
};
// Mock fs operations
mockedFs.existsSync = vi.fn().mockReturnValue(false);
mockedFs.mkdirSync = vi.fn();
mockedFs.createWriteStream = vi.fn().mockReturnValue(mockStream);
mockedFs.createReadStream = vi.fn().mockReturnValue({});
mockedFs.unlinkSync = vi.fn();
processor = new BatchProcessor(options);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('constructor', () => {
it('should create output directory if it does not exist', () => {
expect(mockedFs.existsSync).toHaveBeenCalledWith('./test-temp');
expect(mockedFs.mkdirSync).toHaveBeenCalledWith('./test-temp', { recursive: true });
});
it('should not create directory if it already exists', () => {
mockedFs.existsSync = vi.fn().mockReturnValue(true);
mockedFs.mkdirSync = vi.fn();
new BatchProcessor(options);
expect(mockedFs.mkdirSync).not.toHaveBeenCalled();
});
it('should use default options when not provided', () => {
const minimalOptions = { apiKey: 'test-key' };
const proc = new BatchProcessor(minimalOptions);
expect(proc).toBeDefined();
// Default batchSize is 100, outputDir is './temp'
});
});
describe('processTemplates', () => {
const mockTemplates: MetadataRequest[] = [
{ templateId: 1, name: 'Template 1', nodes: ['n8n-nodes-base.webhook'] },
{ templateId: 2, name: 'Template 2', nodes: ['n8n-nodes-base.slack'] },
{ templateId: 3, name: 'Template 3', nodes: ['n8n-nodes-base.httpRequest'] },
{ templateId: 4, name: 'Template 4', nodes: ['n8n-nodes-base.code'] }
];
// Skipping test - implementation bug: processTemplates returns empty results
it.skip('should process templates in batches correctly', async () => {
// Mock file operations
const mockFile = { id: 'file-123' };
mockClient.files.create.mockResolvedValue(mockFile);
// Mock batch job
const mockBatchJob = {
id: 'batch-123',
status: 'completed',
output_file_id: 'output-file-123'
};
mockClient.batches.create.mockResolvedValue(mockBatchJob);
mockClient.batches.retrieve.mockResolvedValue(mockBatchJob);
// Mock results
const mockFileContent = 'result1\nresult2\nresult3';
mockClient.files.content.mockResolvedValue({ text: () => Promise.resolve(mockFileContent) });
const mockParsedResults = [
{ templateId: 1, metadata: { categories: ['automation'] } },
{ templateId: 2, metadata: { categories: ['communication'] } },
{ templateId: 3, metadata: { categories: ['integration'] } }
];
mockGenerator.parseResult.mockReturnValueOnce(mockParsedResults[0])
.mockReturnValueOnce(mockParsedResults[1])
.mockReturnValueOnce(mockParsedResults[2]);
const progressCallback = vi.fn();
const results = await processor.processTemplates(mockTemplates, progressCallback);
// Should create 2 batches (batchSize = 3, templates = 4)
expect(mockClient.batches.create).toHaveBeenCalledTimes(2);
expect(results.size).toBe(3); // 3 successful results
expect(progressCallback).toHaveBeenCalled();
});
it('should handle empty templates array', async () => {
const results = await processor.processTemplates([]);
expect(results.size).toBe(0);
});
it('should handle batch submission errors gracefully', async () => {
mockClient.files.create.mockRejectedValue(new Error('Upload failed'));
const results = await processor.processTemplates([mockTemplates[0]]);
// Should not throw, should return empty results
expect(results.size).toBe(0);
});
// Skipping: Parallel batch processing creates unhandled promise rejections in tests
// The error handling works in production but the parallel promise structure is
// difficult to test cleanly without refactoring the implementation
it.skip('should handle batch job failures', async () => {
const mockFile = { id: 'file-123' };
mockClient.files.create.mockResolvedValue(mockFile);
const failedBatchJob = {
id: 'batch-123',
status: 'failed'
};
mockClient.batches.create.mockResolvedValue(failedBatchJob);
mockClient.batches.retrieve.mockResolvedValue(failedBatchJob);
const results = await processor.processTemplates([mockTemplates[0]]);
expect(results.size).toBe(0);
});
});
describe('createBatchFile', () => {
it('should create JSONL file with correct format', async () => {
const templates: MetadataRequest[] = [
{ templateId: 1, name: 'Test', nodes: ['node1'] },
{ templateId: 2, name: 'Test2', nodes: ['node2'] }
];
const mockRequest = { custom_id: 'template-1', method: 'POST' };
mockGenerator.createBatchRequest.mockReturnValue(mockRequest);
// Access private method through type assertion
const filename = await (processor as any).createBatchFile(templates, 'test_batch');
expect(mockStream.write).toHaveBeenCalledTimes(2);
expect(mockStream.write).toHaveBeenCalledWith(JSON.stringify(mockRequest) + '\n');
expect(mockStream.end).toHaveBeenCalled();
expect(filename).toContain('test_batch');
});
it('should handle stream errors', async () => {
const templates: MetadataRequest[] = [
{ templateId: 1, name: 'Test', nodes: ['node1'] }
];
// Mock stream error
mockStream.on = vi.fn((event, callback) => {
if (event === 'error') {
setTimeout(() => callback(new Error('Stream error')), 0);
}
});
await expect(
(processor as any).createBatchFile(templates, 'error_batch')
).rejects.toThrow('Stream error');
});
});
describe('uploadFile', () => {
it('should upload file to OpenAI', async () => {
const mockFile = { id: 'uploaded-file-123' };
mockClient.files.create.mockResolvedValue(mockFile);
const result = await (processor as any).uploadFile('/path/to/file.jsonl');
expect(mockClient.files.create).toHaveBeenCalledWith({
file: expect.any(Object),
purpose: 'batch'
});
expect(result).toEqual(mockFile);
});
it('should handle upload errors', async () => {
mockClient.files.create.mockRejectedValue(new Error('Upload failed'));
await expect(
(processor as any).uploadFile('/path/to/file.jsonl')
).rejects.toThrow('Upload failed');
});
});
describe('createBatchJob', () => {
it('should create batch job with correct parameters', async () => {
const mockBatchJob = { id: 'batch-123' };
mockClient.batches.create.mockResolvedValue(mockBatchJob);
const result = await (processor as any).createBatchJob('file-123');
expect(mockClient.batches.create).toHaveBeenCalledWith({
input_file_id: 'file-123',
endpoint: '/v1/chat/completions',
completion_window: '24h'
});
expect(result).toEqual(mockBatchJob);
});
it('should handle batch creation errors', async () => {
mockClient.batches.create.mockRejectedValue(new Error('Batch creation failed'));
await expect(
(processor as any).createBatchJob('file-123')
).rejects.toThrow('Batch creation failed');
});
});
describe('monitorBatchJob', () => {
it('should monitor job until completion', async () => {
const completedJob = { id: 'batch-123', status: 'completed' };
mockClient.batches.retrieve.mockResolvedValue(completedJob);
const result = await (processor as any).monitorBatchJob('batch-123');
expect(mockClient.batches.retrieve).toHaveBeenCalledWith('batch-123');
expect(result).toEqual(completedJob);
});
it('should handle status progression', async () => {
const jobs = [
{ id: 'batch-123', status: 'validating' },
{ id: 'batch-123', status: 'in_progress' },
{ id: 'batch-123', status: 'finalizing' },
{ id: 'batch-123', status: 'completed' }
];
mockClient.batches.retrieve.mockImplementation(() => {
return Promise.resolve(jobs.shift() || jobs[jobs.length - 1]);
});
// Mock sleep to speed up test
const originalSleep = (processor as any).sleep;
(processor as any).sleep = vi.fn().mockResolvedValue(undefined);
const result = await (processor as any).monitorBatchJob('batch-123');
expect(result.status).toBe('completed');
expect(mockClient.batches.retrieve).toHaveBeenCalledTimes(4);
// Restore original sleep method
(processor as any).sleep = originalSleep;
});
it('should throw error for failed jobs', async () => {
const failedJob = { id: 'batch-123', status: 'failed' };
mockClient.batches.retrieve.mockResolvedValue(failedJob);
await expect(
(processor as any).monitorBatchJob('batch-123')
).rejects.toThrow('Batch job failed with status: failed');
});
it('should handle expired jobs', async () => {
const expiredJob = { id: 'batch-123', status: 'expired' };
mockClient.batches.retrieve.mockResolvedValue(expiredJob);
await expect(
(processor as any).monitorBatchJob('batch-123')
).rejects.toThrow('Batch job failed with status: expired');
});
it('should handle cancelled jobs', async () => {
const cancelledJob = { id: 'batch-123', status: 'cancelled' };
mockClient.batches.retrieve.mockResolvedValue(cancelledJob);
await expect(
(processor as any).monitorBatchJob('batch-123')
).rejects.toThrow('Batch job failed with status: cancelled');
});
it('should timeout after max attempts', async () => {
const inProgressJob = { id: 'batch-123', status: 'in_progress' };
mockClient.batches.retrieve.mockResolvedValue(inProgressJob);
// Mock sleep to speed up test
(processor as any).sleep = vi.fn().mockResolvedValue(undefined);
await expect(
(processor as any).monitorBatchJob('batch-123')
).rejects.toThrow('Batch job monitoring timed out');
});
});
describe('retrieveResults', () => {
it('should download and parse results correctly', async () => {
const batchJob = { output_file_id: 'output-123' };
const fileContent = '{"custom_id": "template-1"}\n{"custom_id": "template-2"}';
mockClient.files.content.mockResolvedValue({
text: () => Promise.resolve(fileContent)
});
const mockResults = [
{ templateId: 1, metadata: { categories: ['test'] } },
{ templateId: 2, metadata: { categories: ['test2'] } }
];
mockGenerator.parseResult.mockReturnValueOnce(mockResults[0])
.mockReturnValueOnce(mockResults[1]);
const results = await (processor as any).retrieveResults(batchJob);
expect(mockClient.files.content).toHaveBeenCalledWith('output-123');
expect(mockGenerator.parseResult).toHaveBeenCalledTimes(2);
expect(results).toHaveLength(2);
});
it('should throw error when no output file available', async () => {
const batchJob = { output_file_id: null };
await expect(
(processor as any).retrieveResults(batchJob)
).rejects.toThrow('No output file available for batch job');
});
it('should handle malformed result lines gracefully', async () => {
const batchJob = { output_file_id: 'output-123' };
const fileContent = '{"valid": "json"}\ninvalid json line\n{"another": "valid"}';
mockClient.files.content.mockResolvedValue({
text: () => Promise.resolve(fileContent)
});
const mockValidResult = { templateId: 1, metadata: { categories: ['test'] } };
mockGenerator.parseResult.mockReturnValue(mockValidResult);
const results = await (processor as any).retrieveResults(batchJob);
// Should parse valid lines and skip invalid ones
expect(results).toHaveLength(2);
expect(mockGenerator.parseResult).toHaveBeenCalledTimes(2);
});
it('should handle file download errors', async () => {
const batchJob = { output_file_id: 'output-123' };
mockClient.files.content.mockRejectedValue(new Error('Download failed'));
await expect(
(processor as any).retrieveResults(batchJob)
).rejects.toThrow('Download failed');
});
});
describe('cleanup', () => {
it('should clean up all files successfully', async () => {
await (processor as any).cleanup('local-file.jsonl', 'input-123', 'output-456');
expect(mockedFs.unlinkSync).toHaveBeenCalledWith('local-file.jsonl');
expect(mockClient.files.del).toHaveBeenCalledWith('input-123');
expect(mockClient.files.del).toHaveBeenCalledWith('output-456');
});
it('should handle local file deletion errors gracefully', async () => {
mockedFs.unlinkSync = vi.fn().mockImplementation(() => {
throw new Error('File not found');
});
// Should not throw error
await expect(
(processor as any).cleanup('nonexistent.jsonl', 'input-123')
).resolves.toBeUndefined();
});
it('should handle OpenAI file deletion errors gracefully', async () => {
mockClient.files.del.mockRejectedValue(new Error('Delete failed'));
// Should not throw error
await expect(
(processor as any).cleanup('local-file.jsonl', 'input-123', 'output-456')
).resolves.toBeUndefined();
});
it('should work without output file ID', async () => {
await (processor as any).cleanup('local-file.jsonl', 'input-123');
expect(mockedFs.unlinkSync).toHaveBeenCalledWith('local-file.jsonl');
expect(mockClient.files.del).toHaveBeenCalledWith('input-123');
expect(mockClient.files.del).toHaveBeenCalledTimes(1); // Only input file
});
});
describe('createBatches', () => {
it('should split templates into correct batch sizes', () => {
const templates: MetadataRequest[] = [
{ templateId: 1, name: 'T1', nodes: [] },
{ templateId: 2, name: 'T2', nodes: [] },
{ templateId: 3, name: 'T3', nodes: [] },
{ templateId: 4, name: 'T4', nodes: [] },
{ templateId: 5, name: 'T5', nodes: [] }
];
const batches = (processor as any).createBatches(templates);
expect(batches).toHaveLength(2); // 3 + 2 templates
expect(batches[0]).toHaveLength(3);
expect(batches[1]).toHaveLength(2);
});
it('should handle single template correctly', () => {
const templates = [{ templateId: 1, name: 'T1', nodes: [] }];
const batches = (processor as any).createBatches(templates);
expect(batches).toHaveLength(1);
expect(batches[0]).toHaveLength(1);
});
it('should handle empty templates array', () => {
const batches = (processor as any).createBatches([]);
expect(batches).toHaveLength(0);
});
});
describe('file system security', () => {
// Skipping test - security bug: file paths are not sanitized for directory traversal
it.skip('should sanitize file paths to prevent directory traversal', async () => {
// Test with malicious batch name
const maliciousBatchName = '../../../etc/passwd';
const templates = [{ templateId: 1, name: 'Test', nodes: [] }];
await (processor as any).createBatchFile(templates, maliciousBatchName);
// Should create file in the designated output directory, not escape it
const writtenPath = mockedFs.createWriteStream.mock.calls[0][0];
expect(writtenPath).toMatch(/^\.\/test-temp\//);
expect(writtenPath).not.toContain('../');
});
it('should handle very long file names gracefully', async () => {
const longBatchName = 'a'.repeat(300); // Very long name
const templates = [{ templateId: 1, name: 'Test', nodes: [] }];
await expect(
(processor as any).createBatchFile(templates, longBatchName)
).resolves.toBeDefined();
});
});
describe('memory management', () => {
it('should clean up files even on processing errors', async () => {
const templates = [{ templateId: 1, name: 'Test', nodes: [] }];
// Mock file upload to fail
mockClient.files.create.mockRejectedValue(new Error('Upload failed'));
const submitBatch = (processor as any).submitBatch.bind(processor);
await expect(
submitBatch(templates, 'error_test')
).rejects.toThrow('Upload failed');
// File should still be cleaned up
expect(mockedFs.unlinkSync).toHaveBeenCalled();
});
it('should handle concurrent batch processing correctly', async () => {
const templates = Array.from({ length: 10 }, (_, i) => ({
templateId: i + 1,
name: `Template ${i + 1}`,
nodes: ['node']
}));
// Mock successful processing
mockClient.files.create.mockResolvedValue({ id: 'file-123' });
const completedJob = {
id: 'batch-123',
status: 'completed',
output_file_id: 'output-123'
};
mockClient.batches.create.mockResolvedValue(completedJob);
mockClient.batches.retrieve.mockResolvedValue(completedJob);
mockClient.files.content.mockResolvedValue({
text: () => Promise.resolve('{"custom_id": "template-1"}')
});
mockGenerator.parseResult.mockReturnValue({
templateId: 1,
metadata: { categories: ['test'] }
});
const results = await processor.processTemplates(templates);
expect(results.size).toBeGreaterThan(0);
expect(mockClient.batches.create).toHaveBeenCalled();
});
});
});

View File

@@ -1,475 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MetadataGenerator, TemplateMetadataSchema, MetadataRequest } from '../../../src/templates/metadata-generator';
// Mock OpenAI
vi.mock('openai', () => {
return {
default: vi.fn().mockImplementation(() => ({
chat: {
completions: {
create: vi.fn()
}
}
}))
};
});
describe('MetadataGenerator', () => {
let generator: MetadataGenerator;
beforeEach(() => {
generator = new MetadataGenerator('test-api-key', 'gpt-4o-mini');
});
describe('createBatchRequest', () => {
it('should create a valid batch request', () => {
const template: MetadataRequest = {
templateId: 123,
name: 'Test Workflow',
description: 'A test workflow',
nodes: ['n8n-nodes-base.webhook', 'n8n-nodes-base.httpRequest', 'n8n-nodes-base.slack']
};
const request = generator.createBatchRequest(template);
expect(request.custom_id).toBe('template-123');
expect(request.method).toBe('POST');
expect(request.url).toBe('/v1/chat/completions');
expect(request.body.model).toBe('gpt-4o-mini');
expect(request.body.response_format.type).toBe('json_schema');
expect(request.body.response_format.json_schema.strict).toBe(true);
expect(request.body.messages).toHaveLength(2);
});
it('should summarize nodes effectively', () => {
const template: MetadataRequest = {
templateId: 456,
name: 'Complex Workflow',
nodes: [
'n8n-nodes-base.webhook',
'n8n-nodes-base.httpRequest',
'n8n-nodes-base.httpRequest',
'n8n-nodes-base.postgres',
'n8n-nodes-base.slack',
'@n8n/n8n-nodes-langchain.agent'
]
};
const request = generator.createBatchRequest(template);
const userMessage = request.body.messages[1].content;
expect(userMessage).toContain('Complex Workflow');
expect(userMessage).toContain('Nodes Used (6)');
expect(userMessage).toContain('HTTP/Webhooks');
});
});
describe('parseResult', () => {
it('should parse a successful result', () => {
const mockResult = {
custom_id: 'template-789',
response: {
body: {
choices: [{
message: {
content: JSON.stringify({
categories: ['automation', 'integration'],
complexity: 'medium',
use_cases: ['API integration', 'Data sync'],
estimated_setup_minutes: 30,
required_services: ['Slack API'],
key_features: ['Webhook triggers', 'API calls'],
target_audience: ['developers']
})
},
finish_reason: 'stop'
}]
}
}
};
const result = generator.parseResult(mockResult);
expect(result.templateId).toBe(789);
expect(result.metadata.categories).toEqual(['automation', 'integration']);
expect(result.metadata.complexity).toBe('medium');
expect(result.error).toBeUndefined();
});
it('should handle error results', () => {
const mockResult = {
custom_id: 'template-999',
error: {
message: 'API error'
}
};
const result = generator.parseResult(mockResult);
expect(result.templateId).toBe(999);
expect(result.error).toBe('API error');
expect(result.metadata).toBeDefined();
expect(result.metadata.complexity).toBe('medium'); // Default metadata
});
it('should handle malformed responses', () => {
const mockResult = {
custom_id: 'template-111',
response: {
body: {
choices: [{
message: {
content: 'not valid json'
},
finish_reason: 'stop'
}]
}
}
};
const result = generator.parseResult(mockResult);
expect(result.templateId).toBe(111);
expect(result.error).toContain('Unexpected token');
expect(result.metadata).toBeDefined();
});
});
describe('TemplateMetadataSchema', () => {
it('should validate correct metadata', () => {
const validMetadata = {
categories: ['automation', 'integration'],
complexity: 'simple' as const,
use_cases: ['API calls', 'Data processing'],
estimated_setup_minutes: 15,
required_services: [],
key_features: ['Fast processing'],
target_audience: ['developers']
};
const result = TemplateMetadataSchema.safeParse(validMetadata);
expect(result.success).toBe(true);
});
it('should reject invalid complexity', () => {
const invalidMetadata = {
categories: ['automation'],
complexity: 'very-hard', // Invalid
use_cases: ['API calls'],
estimated_setup_minutes: 15,
required_services: [],
key_features: ['Fast'],
target_audience: ['developers']
};
const result = TemplateMetadataSchema.safeParse(invalidMetadata);
expect(result.success).toBe(false);
});
it('should enforce array limits', () => {
const tooManyCategories = {
categories: ['a', 'b', 'c', 'd', 'e', 'f'], // Max 5
complexity: 'simple' as const,
use_cases: ['API calls'],
estimated_setup_minutes: 15,
required_services: [],
key_features: ['Fast'],
target_audience: ['developers']
};
const result = TemplateMetadataSchema.safeParse(tooManyCategories);
expect(result.success).toBe(false);
});
it('should enforce time limits', () => {
const tooLongSetup = {
categories: ['automation'],
complexity: 'complex' as const,
use_cases: ['API calls'],
estimated_setup_minutes: 500, // Max 480
required_services: [],
key_features: ['Fast'],
target_audience: ['developers']
};
const result = TemplateMetadataSchema.safeParse(tooLongSetup);
expect(result.success).toBe(false);
});
});
describe('Input Sanitization and Security', () => {
it('should handle malicious template names safely', () => {
const maliciousTemplate: MetadataRequest = {
templateId: 123,
name: '<script>alert("xss")</script>',
description: 'javascript:alert(1)',
nodes: ['n8n-nodes-base.webhook']
};
const request = generator.createBatchRequest(maliciousTemplate);
const userMessage = request.body.messages[1].content;
// Should contain the malicious content as-is (OpenAI will handle it)
// but should not cause any injection in our code
expect(userMessage).toContain('<script>alert("xss")</script>');
expect(userMessage).toContain('javascript:alert(1)');
expect(request.body.model).toBe('gpt-4o-mini');
});
it('should handle extremely long template names', () => {
const longName = 'A'.repeat(10000); // Very long name
const template: MetadataRequest = {
templateId: 456,
name: longName,
nodes: ['n8n-nodes-base.webhook']
};
const request = generator.createBatchRequest(template);
expect(request.custom_id).toBe('template-456');
expect(request.body.messages[1].content).toContain(longName);
});
it('should handle special characters in node names', () => {
const template: MetadataRequest = {
templateId: 789,
name: 'Test Workflow',
nodes: [
'n8n-nodes-base.webhook',
'@n8n/custom-node.with.dots',
'custom-package/node-with-slashes',
'node_with_underscore',
'node-with-unicode-名前'
]
};
const request = generator.createBatchRequest(template);
const userMessage = request.body.messages[1].content;
expect(userMessage).toContain('HTTP/Webhooks');
expect(userMessage).toContain('custom-node.with.dots');
});
it('should handle empty or undefined descriptions safely', () => {
const template: MetadataRequest = {
templateId: 100,
name: 'Test',
description: undefined,
nodes: ['n8n-nodes-base.webhook']
};
const request = generator.createBatchRequest(template);
const userMessage = request.body.messages[1].content;
// Should not include undefined or null in the message
expect(userMessage).not.toContain('undefined');
expect(userMessage).not.toContain('null');
expect(userMessage).toContain('Test');
});
it('should limit context size for very large workflows', () => {
const manyNodes = Array.from({ length: 1000 }, (_, i) => `n8n-nodes-base.node${i}`);
const template: MetadataRequest = {
templateId: 200,
name: 'Huge Workflow',
nodes: manyNodes,
workflow: {
nodes: Array.from({ length: 500 }, (_, i) => ({ id: `node${i}` })),
connections: {}
}
};
const request = generator.createBatchRequest(template);
const userMessage = request.body.messages[1].content;
// Should handle large amounts of data gracefully
expect(userMessage.length).toBeLessThan(50000); // Reasonable limit
expect(userMessage).toContain('Huge Workflow');
});
});
describe('Error Handling and Edge Cases', () => {
it('should handle malformed OpenAI responses', () => {
const malformedResults = [
{
custom_id: 'template-111',
response: {
body: {
choices: [{
message: {
content: '{"invalid": json syntax}'
},
finish_reason: 'stop'
}]
}
}
},
{
custom_id: 'template-222',
response: {
body: {
choices: [{
message: {
content: null
},
finish_reason: 'stop'
}]
}
}
},
{
custom_id: 'template-333',
response: {
body: {
choices: []
}
}
}
];
malformedResults.forEach(result => {
const parsed = generator.parseResult(result);
expect(parsed.error).toBeDefined();
expect(parsed.metadata).toBeDefined();
expect(parsed.metadata.complexity).toBe('medium'); // Default metadata
});
});
it('should handle Zod validation failures', () => {
const invalidResponse = {
custom_id: 'template-444',
response: {
body: {
choices: [{
message: {
content: JSON.stringify({
categories: ['too', 'many', 'categories', 'here', 'way', 'too', 'many'],
complexity: 'invalid-complexity',
use_cases: [],
estimated_setup_minutes: -5, // Invalid negative time
required_services: 'not-an-array',
key_features: null,
target_audience: ['too', 'many', 'audiences', 'here']
})
},
finish_reason: 'stop'
}]
}
}
};
const result = generator.parseResult(invalidResponse);
expect(result.templateId).toBe(444);
expect(result.error).toBeDefined();
expect(result.metadata).toEqual(generator['getDefaultMetadata']());
});
it('should handle network timeouts gracefully in generateSingle', async () => {
// Create a new generator with mocked OpenAI client
const mockClient = {
chat: {
completions: {
create: vi.fn().mockRejectedValue(new Error('Request timed out'))
}
}
};
// Override the client property using Object.defineProperty
Object.defineProperty(generator, 'client', {
value: mockClient,
writable: true
});
const template: MetadataRequest = {
templateId: 555,
name: 'Timeout Test',
nodes: ['n8n-nodes-base.webhook']
};
const result = await generator.generateSingle(template);
// Should return default metadata instead of throwing
expect(result).toEqual(generator['getDefaultMetadata']());
});
});
describe('Node Summarization Logic', () => {
it('should group similar nodes correctly', () => {
const template: MetadataRequest = {
templateId: 666,
name: 'Complex Workflow',
nodes: [
'n8n-nodes-base.webhook',
'n8n-nodes-base.httpRequest',
'n8n-nodes-base.postgres',
'n8n-nodes-base.mysql',
'n8n-nodes-base.slack',
'n8n-nodes-base.gmail',
'@n8n/n8n-nodes-langchain.openAi',
'@n8n/n8n-nodes-langchain.agent',
'n8n-nodes-base.googleSheets',
'n8n-nodes-base.excel'
]
};
const request = generator.createBatchRequest(template);
const userMessage = request.body.messages[1].content;
expect(userMessage).toContain('HTTP/Webhooks (2)');
expect(userMessage).toContain('Database (2)');
expect(userMessage).toContain('Communication (2)');
expect(userMessage).toContain('AI/ML (2)');
expect(userMessage).toContain('Spreadsheets (2)');
});
it('should handle unknown node types gracefully', () => {
const template: MetadataRequest = {
templateId: 777,
name: 'Unknown Nodes',
nodes: [
'custom-package.unknownNode',
'another-package.weirdNodeType',
'someNodeTrigger',
'anotherNode'
]
};
const request = generator.createBatchRequest(template);
const userMessage = request.body.messages[1].content;
// Should handle unknown nodes without crashing
expect(userMessage).toContain('unknownNode');
expect(userMessage).toContain('weirdNodeType');
expect(userMessage).toContain('someNode'); // Trigger suffix removed
});
it('should limit node summary length', () => {
const manyNodes = Array.from({ length: 50 }, (_, i) =>
`n8n-nodes-base.customNode${i}`
);
const template: MetadataRequest = {
templateId: 888,
name: 'Many Nodes',
nodes: manyNodes
};
const request = generator.createBatchRequest(template);
const userMessage = request.body.messages[1].content;
// Should limit to top 10 groups
const summaryLine = userMessage.split('\n').find((line: string) =>
line.includes('Nodes Used (50)')
);
expect(summaryLine).toBeDefined();
const nodeGroups = summaryLine!.split(': ')[1].split(', ');
expect(nodeGroups.length).toBeLessThanOrEqual(10);
});
});
});

View File

@@ -1,527 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TemplateRepository } from '../../../src/templates/template-repository';
import { DatabaseAdapter, PreparedStatement, RunResult } from '../../../src/database/database-adapter';
// Mock logger
vi.mock('../../../src/utils/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn()
}
}));
// Mock template sanitizer
vi.mock('../../../src/utils/template-sanitizer', () => {
class MockTemplateSanitizer {
sanitizeWorkflow = vi.fn((workflow) => ({ sanitized: workflow, wasModified: false }));
detectTokens = vi.fn(() => []);
}
return {
TemplateSanitizer: MockTemplateSanitizer
};
});
// Create mock database adapter
class MockDatabaseAdapter implements DatabaseAdapter {
private statements = new Map<string, MockPreparedStatement>();
private execCalls: string[] = [];
private _fts5Support = true;
prepare = vi.fn((sql: string) => {
if (!this.statements.has(sql)) {
this.statements.set(sql, new MockPreparedStatement(sql));
}
return this.statements.get(sql)!;
});
exec = vi.fn((sql: string) => {
this.execCalls.push(sql);
});
close = vi.fn();
pragma = vi.fn();
transaction = vi.fn((fn: () => any) => fn());
checkFTS5Support = vi.fn(() => this._fts5Support);
inTransaction = false;
// Test helpers
_setFTS5Support(supported: boolean) {
this._fts5Support = supported;
}
_getStatement(sql: string) {
return this.statements.get(sql);
}
_getExecCalls() {
return this.execCalls;
}
_clearExecCalls() {
this.execCalls = [];
}
}
class MockPreparedStatement implements PreparedStatement {
public mockResults: any[] = [];
public capturedParams: any[][] = [];
run = vi.fn((...params: any[]): RunResult => {
this.capturedParams.push(params);
return { changes: 1, lastInsertRowid: 1 };
});
get = vi.fn((...params: any[]) => {
this.capturedParams.push(params);
return this.mockResults[0] || null;
});
all = vi.fn((...params: any[]) => {
this.capturedParams.push(params);
return this.mockResults;
});
iterate = vi.fn();
pluck = vi.fn(() => this);
expand = vi.fn(() => this);
raw = vi.fn(() => this);
columns = vi.fn(() => []);
bind = vi.fn(() => this);
constructor(private sql: string) {}
// Test helpers
_setMockResults(results: any[]) {
this.mockResults = results;
}
_getCapturedParams() {
return this.capturedParams;
}
}
describe('TemplateRepository - Security Tests', () => {
let repository: TemplateRepository;
let mockAdapter: MockDatabaseAdapter;
beforeEach(() => {
vi.clearAllMocks();
mockAdapter = new MockDatabaseAdapter();
repository = new TemplateRepository(mockAdapter);
});
describe('SQL Injection Prevention', () => {
describe('searchTemplatesByMetadata', () => {
it('should prevent SQL injection in category parameter', () => {
const maliciousCategory = "'; DROP TABLE templates; --";
const stmt = new MockPreparedStatement('');
stmt._setMockResults([]);
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
repository.searchTemplatesByMetadata({
category: maliciousCategory}, 10, 0);
// Should use parameterized queries, not inject SQL
const capturedParams = stmt._getCapturedParams();
expect(capturedParams.length).toBeGreaterThan(0);
// The parameter should be the sanitized version (JSON.stringify then slice to remove quotes)
const expectedParam = JSON.stringify(maliciousCategory).slice(1, -1);
// capturedParams[0] is the first call's parameters array
expect(capturedParams[0][0]).toBe(expectedParam);
// Verify the SQL doesn't contain the malicious content directly
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
expect(prepareCall).not.toContain('DROP TABLE');
expect(prepareCall).toContain("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
});
it('should prevent SQL injection in requiredService parameter', () => {
const maliciousService = "'; UNION SELECT * FROM sqlite_master; --";
const stmt = new MockPreparedStatement('');
stmt._setMockResults([]);
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
repository.searchTemplatesByMetadata({
requiredService: maliciousService}, 10, 0);
const capturedParams = stmt._getCapturedParams();
const expectedParam = JSON.stringify(maliciousService).slice(1, -1);
// capturedParams[0] is the first call's parameters array
expect(capturedParams[0][0]).toBe(expectedParam);
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
expect(prepareCall).not.toContain('UNION SELECT');
expect(prepareCall).toContain("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'");
});
it('should prevent SQL injection in targetAudience parameter', () => {
const maliciousAudience = "administrators'; DELETE FROM templates WHERE '1'='1";
const stmt = new MockPreparedStatement('');
stmt._setMockResults([]);
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
repository.searchTemplatesByMetadata({
targetAudience: maliciousAudience}, 10, 0);
const capturedParams = stmt._getCapturedParams();
const expectedParam = JSON.stringify(maliciousAudience).slice(1, -1);
// capturedParams[0] is the first call's parameters array
expect(capturedParams[0][0]).toBe(expectedParam);
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
expect(prepareCall).not.toContain('DELETE FROM');
expect(prepareCall).toContain("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'");
});
it('should safely handle special characters in parameters', () => {
const specialChars = "test'with\"quotes\\and%wildcards_and[brackets]";
const stmt = new MockPreparedStatement('');
stmt._setMockResults([]);
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
repository.searchTemplatesByMetadata({
category: specialChars}, 10, 0);
const capturedParams = stmt._getCapturedParams();
const expectedParam = JSON.stringify(specialChars).slice(1, -1);
// capturedParams[0] is the first call's parameters array
expect(capturedParams[0][0]).toBe(expectedParam);
// Should use parameterized query
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
expect(prepareCall).toContain("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
});
it('should prevent injection through numeric parameters', () => {
const stmt = new MockPreparedStatement('');
stmt._setMockResults([]);
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
// Try to inject through numeric parameters
repository.searchTemplatesByMetadata({maxSetupMinutes: 999999999, // Large number
minSetupMinutes: -999999999 // Negative number
}, 10, 0);
const capturedParams = stmt._getCapturedParams();
// capturedParams[0] is the first call's parameters array
expect(capturedParams[0]).toContain(999999999);
expect(capturedParams[0]).toContain(-999999999);
// Should use CAST and parameterized queries
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
expect(prepareCall).toContain('CAST(json_extract(metadata_json, \'$.estimated_setup_minutes\') AS INTEGER)');
});
});
describe('getMetadataSearchCount', () => {
it('should use parameterized queries for count operations', () => {
const maliciousCategory = "'; DROP TABLE templates; SELECT COUNT(*) FROM sqlite_master WHERE name LIKE '%";
const stmt = new MockPreparedStatement('');
stmt._setMockResults([{ count: 0 }]);
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
repository.getMetadataSearchCount({
category: maliciousCategory
});
const capturedParams = stmt._getCapturedParams();
const expectedParam = JSON.stringify(maliciousCategory).slice(1, -1);
// capturedParams[0] is the first call's parameters array
expect(capturedParams[0][0]).toBe(expectedParam);
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
expect(prepareCall).not.toContain('DROP TABLE');
expect(prepareCall).toContain('SELECT COUNT(*) as count FROM templates');
});
});
describe('updateTemplateMetadata', () => {
it('should safely handle metadata with special characters', () => {
const maliciousMetadata = {
categories: ["automation'; DROP TABLE templates; --"],
complexity: "simple",
use_cases: ['SQL injection"test'],
estimated_setup_minutes: 30,
required_services: ['api"with\\"quotes'],
key_features: ["feature's test"],
target_audience: ['developers\\administrators']
};
const stmt = new MockPreparedStatement('');
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
repository.updateTemplateMetadata(123, maliciousMetadata);
const capturedParams = stmt._getCapturedParams();
expect(capturedParams[0][0]).toBe(JSON.stringify(maliciousMetadata));
expect(capturedParams[0][1]).toBe(123);
// Should use parameterized UPDATE
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
expect(prepareCall).toContain('UPDATE templates');
expect(prepareCall).toContain('metadata_json = ?');
expect(prepareCall).toContain('WHERE id = ?');
expect(prepareCall).not.toContain('DROP TABLE');
});
});
describe('batchUpdateMetadata', () => {
it('should safely handle batch updates with malicious data', () => {
const maliciousData = new Map();
maliciousData.set(1, { categories: ["'; DROP TABLE templates; --"] });
maliciousData.set(2, { categories: ["normal category"] });
const stmt = new MockPreparedStatement('');
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
repository.batchUpdateMetadata(maliciousData);
const capturedParams = stmt._getCapturedParams();
expect(capturedParams).toHaveLength(2);
// Both calls should be parameterized
const firstJson = capturedParams[0][0];
const secondJson = capturedParams[1][0];
expect(firstJson).toContain("'; DROP TABLE templates; --"); // Should be JSON-encoded
expect(capturedParams[0][1]).toBe(1);
expect(secondJson).toContain('normal category');
expect(capturedParams[1][1]).toBe(2);
});
});
});
describe('JSON Extraction Security', () => {
it('should safely extract categories from JSON', () => {
const stmt = new MockPreparedStatement('');
stmt._setMockResults([]);
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
repository.getUniqueCategories();
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
expect(prepareCall).toContain('json_each(metadata_json, \'$.categories\')');
expect(prepareCall).not.toContain('eval(');
expect(prepareCall).not.toContain('exec(');
});
it('should safely extract target audiences from JSON', () => {
const stmt = new MockPreparedStatement('');
stmt._setMockResults([]);
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
repository.getUniqueTargetAudiences();
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
expect(prepareCall).toContain('json_each(metadata_json, \'$.target_audience\')');
expect(prepareCall).not.toContain('eval(');
});
it('should safely handle complex JSON structures', () => {
const stmt = new MockPreparedStatement('');
stmt._setMockResults([]);
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
repository.getTemplatesByCategory('test');
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
expect(prepareCall).toContain("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
const capturedParams = stmt._getCapturedParams();
// Check if parameters were captured
expect(capturedParams.length).toBeGreaterThan(0);
// Find the parameter that contains 'test'
const testParam = capturedParams[0].find((p: any) => typeof p === 'string' && p.includes('test'));
expect(testParam).toBe('test');
});
});
describe('Input Validation and Sanitization', () => {
it('should handle null and undefined parameters safely', () => {
const stmt = new MockPreparedStatement('');
stmt._setMockResults([]);
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
repository.searchTemplatesByMetadata({
category: undefined as any,
complexity: null as any}, 10, 0);
// Should not break and should exclude undefined/null filters
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
expect(prepareCall).toContain('metadata_json IS NOT NULL');
expect(prepareCall).not.toContain('undefined');
expect(prepareCall).not.toContain('null');
});
it('should handle empty string parameters', () => {
const stmt = new MockPreparedStatement('');
stmt._setMockResults([]);
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
repository.searchTemplatesByMetadata({
category: '',
requiredService: '',
targetAudience: ''}, 10, 0);
// Empty strings should still be processed (might be valid searches)
const capturedParams = stmt._getCapturedParams();
const expectedParam = JSON.stringify("").slice(1, -1); // Results in empty string
// Check if parameters were captured
expect(capturedParams.length).toBeGreaterThan(0);
// Check if empty string parameters are present
const hasEmptyString = capturedParams[0].includes(expectedParam);
expect(hasEmptyString).toBe(true);
});
it('should validate numeric ranges', () => {
const stmt = new MockPreparedStatement('');
stmt._setMockResults([]);
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
repository.searchTemplatesByMetadata({
maxSetupMinutes: Number.MAX_SAFE_INTEGER,
minSetupMinutes: Number.MIN_SAFE_INTEGER}, 10, 0);
// Should handle extreme values without breaking
const capturedParams = stmt._getCapturedParams();
expect(capturedParams[0]).toContain(Number.MAX_SAFE_INTEGER);
expect(capturedParams[0]).toContain(Number.MIN_SAFE_INTEGER);
});
it('should handle Unicode and international characters', () => {
const unicodeCategory = '自動化'; // Japanese for "automation"
const emojiAudience = '👩‍💻 developers';
const stmt = new MockPreparedStatement('');
stmt._setMockResults([]);
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
repository.searchTemplatesByMetadata({
category: unicodeCategory,
targetAudience: emojiAudience}, 10, 0);
const capturedParams = stmt._getCapturedParams();
const expectedCategoryParam = JSON.stringify(unicodeCategory).slice(1, -1);
const expectedAudienceParam = JSON.stringify(emojiAudience).slice(1, -1);
// capturedParams[0] is the first call's parameters array
expect(capturedParams[0][0]).toBe(expectedCategoryParam);
expect(capturedParams[0][1]).toBe(expectedAudienceParam);
});
});
describe('Database Schema Security', () => {
it('should use proper column names without injection', () => {
const stmt = new MockPreparedStatement('');
stmt._setMockResults([]);
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
repository.searchTemplatesByMetadata({
category: 'test'}, 10, 0);
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
// Should reference proper column names
expect(prepareCall).toContain('metadata_json');
expect(prepareCall).toContain('templates');
// Should not contain dynamic column names that could be injected
expect(prepareCall).not.toMatch(/SELECT \* FROM \w+;/);
expect(prepareCall).not.toContain('information_schema');
expect(prepareCall).not.toContain('sqlite_master');
});
it('should use proper JSON path syntax', () => {
const stmt = new MockPreparedStatement('');
stmt._setMockResults([]);
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
repository.getUniqueCategories();
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
// Should use safe JSON path syntax
expect(prepareCall).toContain('$.categories');
expect(prepareCall).not.toContain('$[');
expect(prepareCall).not.toContain('eval(');
});
});
describe('Transaction Safety', () => {
it('should handle transaction rollback on metadata update errors', () => {
const stmt = new MockPreparedStatement('');
stmt.run = vi.fn().mockImplementation(() => {
throw new Error('Database error');
});
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
const maliciousData = new Map();
maliciousData.set(1, { categories: ["'; DROP TABLE templates; --"] });
expect(() => {
repository.batchUpdateMetadata(maliciousData);
}).toThrow('Database error');
// The error is thrown when running the statement, not during transaction setup
// So we just verify that the error was thrown correctly
});
});
describe('Error Message Security', () => {
it('should not expose sensitive information in error messages', () => {
const stmt = new MockPreparedStatement('');
stmt.get = vi.fn().mockImplementation(() => {
throw new Error('SQLITE_ERROR: syntax error near "DROP TABLE"');
});
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
expect(() => {
repository.getMetadataSearchCount({
category: "'; DROP TABLE templates; --"
});
}).toThrow(); // Should throw, but not expose SQL details
});
});
describe('Performance and DoS Protection', () => {
it('should handle large limit values safely', () => {
const stmt = new MockPreparedStatement('');
stmt._setMockResults([]);
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
repository.searchTemplatesByMetadata({}, 999999999, 0); // Very large limit
const capturedParams = stmt._getCapturedParams();
// Check if parameters were captured
expect(capturedParams.length).toBeGreaterThan(0);
// Check if the large limit value is present (might be capped)
const hasLargeLimit = capturedParams[0].includes(999999999) || capturedParams[0].includes(20);
expect(hasLargeLimit).toBe(true);
// Should still work but might be limited by database constraints
expect(mockAdapter.prepare).toHaveBeenCalled();
});
it('should handle very long string parameters', () => {
const veryLongString = 'a'.repeat(100000); // 100KB string
const stmt = new MockPreparedStatement('');
stmt._setMockResults([]);
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
repository.searchTemplatesByMetadata({
category: veryLongString}, 10, 0);
const capturedParams = stmt._getCapturedParams();
expect(capturedParams[0][0]).toContain(veryLongString);
// Should handle without breaking
expect(mockAdapter.prepare).toHaveBeenCalled();
});
});
});

View File

@@ -357,11 +357,9 @@ describe('Database Utils', () => {
it('should measure operation duration', async () => {
const duration = await measureDatabaseOperation('test operation', async () => {
await seedTestNodes(testDb.nodeRepository);
// Add a small delay to ensure measurable time passes
await new Promise(resolve => setTimeout(resolve, 1));
});
expect(duration).toBeGreaterThanOrEqual(0);
expect(duration).toBeGreaterThan(0);
expect(duration).toBeLessThan(1000); // Should be fast
});
});

View File

@@ -1,198 +0,0 @@
import { describe, it, expect } from 'vitest';
import { resolveTemplateNodeTypes } from '../../../src/utils/template-node-resolver';
describe('Template Node Resolver', () => {
describe('resolveTemplateNodeTypes', () => {
it('should handle bare node names', () => {
const result = resolveTemplateNodeTypes(['slack']);
expect(result).toContain('n8n-nodes-base.slack');
expect(result).toContain('n8n-nodes-base.slackTrigger');
});
it('should handle HTTP variations', () => {
const result = resolveTemplateNodeTypes(['http']);
expect(result).toContain('n8n-nodes-base.httpRequest');
expect(result).toContain('n8n-nodes-base.webhook');
});
it('should handle httpRequest variations', () => {
const result = resolveTemplateNodeTypes(['httprequest']);
expect(result).toContain('n8n-nodes-base.httpRequest');
});
it('should handle partial prefix formats', () => {
const result = resolveTemplateNodeTypes(['nodes-base.webhook']);
expect(result).toContain('n8n-nodes-base.webhook');
expect(result).not.toContain('nodes-base.webhook');
});
it('should handle langchain nodes', () => {
const result = resolveTemplateNodeTypes(['nodes-langchain.agent']);
expect(result).toContain('@n8n/n8n-nodes-langchain.agent');
expect(result).not.toContain('nodes-langchain.agent');
});
it('should handle already correct formats', () => {
const input = ['n8n-nodes-base.slack', '@n8n/n8n-nodes-langchain.agent'];
const result = resolveTemplateNodeTypes(input);
expect(result).toContain('n8n-nodes-base.slack');
expect(result).toContain('@n8n/n8n-nodes-langchain.agent');
});
it('should handle Google services', () => {
const result = resolveTemplateNodeTypes(['google']);
expect(result).toContain('n8n-nodes-base.googleSheets');
expect(result).toContain('n8n-nodes-base.googleDrive');
expect(result).toContain('n8n-nodes-base.googleCalendar');
});
it('should handle database variations', () => {
const result = resolveTemplateNodeTypes(['database']);
expect(result).toContain('n8n-nodes-base.postgres');
expect(result).toContain('n8n-nodes-base.mysql');
expect(result).toContain('n8n-nodes-base.mongoDb');
expect(result).toContain('n8n-nodes-base.postgresDatabase');
expect(result).toContain('n8n-nodes-base.mysqlDatabase');
});
it('should handle AI/LLM variations', () => {
const result = resolveTemplateNodeTypes(['ai']);
expect(result).toContain('n8n-nodes-base.openAi');
expect(result).toContain('@n8n/n8n-nodes-langchain.agent');
expect(result).toContain('@n8n/n8n-nodes-langchain.lmChatOpenAi');
});
it('should handle email variations', () => {
const result = resolveTemplateNodeTypes(['email']);
expect(result).toContain('n8n-nodes-base.emailSend');
expect(result).toContain('n8n-nodes-base.emailReadImap');
expect(result).toContain('n8n-nodes-base.gmail');
expect(result).toContain('n8n-nodes-base.gmailTrigger');
});
it('should handle schedule/cron variations', () => {
const result = resolveTemplateNodeTypes(['schedule']);
expect(result).toContain('n8n-nodes-base.scheduleTrigger');
expect(result).toContain('n8n-nodes-base.cron');
});
it('should handle multiple inputs', () => {
const result = resolveTemplateNodeTypes(['slack', 'webhook', 'http']);
expect(result).toContain('n8n-nodes-base.slack');
expect(result).toContain('n8n-nodes-base.slackTrigger');
expect(result).toContain('n8n-nodes-base.webhook');
expect(result).toContain('n8n-nodes-base.httpRequest');
});
it('should not duplicate entries', () => {
const result = resolveTemplateNodeTypes(['slack', 'n8n-nodes-base.slack']);
const slackCount = result.filter(r => r === 'n8n-nodes-base.slack').length;
expect(slackCount).toBe(1);
});
it('should handle mixed case inputs', () => {
const result = resolveTemplateNodeTypes(['Slack', 'WEBHOOK', 'HttpRequest']);
expect(result).toContain('n8n-nodes-base.slack');
expect(result).toContain('n8n-nodes-base.webhook');
expect(result).toContain('n8n-nodes-base.httpRequest');
});
it('should handle common misspellings', () => {
const result = resolveTemplateNodeTypes(['postgres', 'postgresql']);
expect(result).toContain('n8n-nodes-base.postgres');
expect(result).toContain('n8n-nodes-base.postgresDatabase');
});
it('should handle code/javascript/python variations', () => {
const result = resolveTemplateNodeTypes(['javascript', 'python', 'js']);
result.forEach(() => {
expect(result).toContain('n8n-nodes-base.code');
});
});
it('should handle trigger suffix variations', () => {
const result = resolveTemplateNodeTypes(['slacktrigger', 'gmailtrigger']);
expect(result).toContain('n8n-nodes-base.slackTrigger');
expect(result).toContain('n8n-nodes-base.gmailTrigger');
});
it('should handle sheet/sheets variations', () => {
const result = resolveTemplateNodeTypes(['googlesheet', 'googlesheets']);
result.forEach(() => {
expect(result).toContain('n8n-nodes-base.googleSheets');
});
});
it('should return empty array for empty input', () => {
const result = resolveTemplateNodeTypes([]);
expect(result).toEqual([]);
});
});
describe('Edge cases', () => {
it('should handle undefined-like strings gracefully', () => {
const result = resolveTemplateNodeTypes(['undefined', 'null', '']);
// Should process them as regular strings
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
});
it('should handle very long node names', () => {
const longName = 'a'.repeat(100);
const result = resolveTemplateNodeTypes([longName]);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
});
it('should handle special characters in node names', () => {
const result = resolveTemplateNodeTypes(['node-with-dashes', 'node_with_underscores']);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
});
});
describe('Real-world scenarios from AI agents', () => {
it('should handle common AI agent queries', () => {
// These are actual queries that AI agents commonly try
const testCases = [
{ input: ['slack'], shouldContain: 'n8n-nodes-base.slack' },
{ input: ['webhook'], shouldContain: 'n8n-nodes-base.webhook' },
{ input: ['http'], shouldContain: 'n8n-nodes-base.httpRequest' },
{ input: ['email'], shouldContain: 'n8n-nodes-base.gmail' },
{ input: ['gpt'], shouldContain: 'n8n-nodes-base.openAi' },
{ input: ['chatgpt'], shouldContain: 'n8n-nodes-base.openAi' },
{ input: ['agent'], shouldContain: '@n8n/n8n-nodes-langchain.agent' },
{ input: ['sql'], shouldContain: 'n8n-nodes-base.postgres' },
{ input: ['api'], shouldContain: 'n8n-nodes-base.httpRequest' },
{ input: ['csv'], shouldContain: 'n8n-nodes-base.spreadsheetFile' },
];
testCases.forEach(({ input, shouldContain }) => {
const result = resolveTemplateNodeTypes(input);
expect(result).toContain(shouldContain);
});
});
});
});

View File

@@ -280,7 +280,7 @@ export function createTestTemplate(overrides: Partial<TemplateWorkflow> = {}): T
verified: false
},
createdAt: overrides.createdAt || new Date().toISOString(),
totalViews: overrides.totalViews || 100,
totalViews: overrides.totalViews || 0,
...overrides
};
}