diff --git a/cypress/e2e/17-sharing.cy.ts b/cypress/e2e/17-sharing.cy.ts
index 77d9fd92cfc4a..71f41250eca77 100644
--- a/cypress/e2e/17-sharing.cy.ts
+++ b/cypress/e2e/17-sharing.cy.ts
@@ -98,6 +98,26 @@ describe('Sharing', { disableAutoLogin: true }, () => {
ndv.actions.close();
});
+ it('should open W1, add node using C2 as U2', () => {
+ cy.signin(INSTANCE_MEMBERS[0]);
+
+ cy.visit(workflowsPage.url);
+ workflowsPage.getters.workflowCards().should('have.length', 2);
+ workflowsPage.getters.workflowCard('Workflow W1').click();
+ workflowPage.actions.addNodeToCanvas('Airtable', true, true);
+ ndv.getters.credentialInput().find('input').should('have.value', 'Credential C2');
+ ndv.actions.close();
+ workflowPage.actions.saveWorkflowOnButtonClick();
+
+ workflowPage.actions.openNode('Notion');
+ ndv.getters
+ .credentialInput()
+ .find('input')
+ .should('have.value', 'Credential C1')
+ .should('be.enabled');
+ ndv.actions.close();
+ });
+
it('should not have access to W2, as U3', () => {
cy.signin(INSTANCE_MEMBERS[1]);
diff --git a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserItemList/OutputParserItemList.node.ts b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserItemList/OutputParserItemList.node.ts
index 57896165c8eb4..24327b2970e2f 100644
--- a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserItemList/OutputParserItemList.node.ts
+++ b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserItemList/OutputParserItemList.node.ts
@@ -55,7 +55,7 @@ export class OutputParserItemList implements INodeType {
type: 'number',
default: -1,
description:
- 'Defines many many items should be returned maximally. If set to -1, there is no limit.',
+ 'Defines how many items should be returned maximally. If set to -1, there is no limit.',
},
// For that to be easily possible the metadata would have to be returned and be able to be read.
// Would also be possible with a wrapper but that would be even more hacky and the output types
diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolSerpApi/ToolSerpApi.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolSerpApi/ToolSerpApi.node.ts
index 44e75881e589f..77147abdc2868 100644
--- a/packages/@n8n/nodes-langchain/nodes/tools/ToolSerpApi/ToolSerpApi.node.ts
+++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolSerpApi/ToolSerpApi.node.ts
@@ -97,7 +97,7 @@ export class ToolSerpApi implements INodeType {
type: 'string',
default: 'google.com',
description:
- 'Defines the country to use for search. Head to Google countries page for a full list of supported countries.',
+ 'Defines the domain to use for search. Head to Google domains page for a full list of supported domains.',
},
{
displayName: 'Language',
diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts
index 413c2e53072e7..fc8b28c0df4fb 100644
--- a/packages/cli/src/commands/BaseCommand.ts
+++ b/packages/cli/src/commands/BaseCommand.ts
@@ -80,6 +80,12 @@ export abstract class BaseCommand extends Command {
);
}
+ if (config.getEnv('executions.mode') === 'queue' && dbType === 'sqlite') {
+ this.logger.warn(
+ 'Queue mode is not officially supported with sqlite. Please switch to PostgreSQL.',
+ );
+ }
+
if (
process.env.N8N_BINARY_DATA_TTL ??
process.env.N8N_PERSISTED_BINARY_DATA_TTL ??
diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts
index 856b4fa43b39b..07b4ce98fe491 100644
--- a/packages/cli/src/commands/worker.ts
+++ b/packages/cli/src/commands/worker.ts
@@ -13,7 +13,13 @@ import type {
INodeTypes,
IRun,
} from 'n8n-workflow';
-import { Workflow, NodeOperationError, sleep, ApplicationError } from 'n8n-workflow';
+import {
+ Workflow,
+ NodeOperationError,
+ sleep,
+ ApplicationError,
+ ErrorReporterProxy as EventReporter,
+} from 'n8n-workflow';
import * as Db from '@/Db';
import * as ResponseHelper from '@/ResponseHelper';
@@ -130,7 +136,15 @@ export class Worker extends BaseCommand {
{ extra: { executionId } },
);
}
- const workflowId = fullExecutionData.workflowData.id!;
+ const workflowId = fullExecutionData.workflowData.id!; // @tech_debt Ensure this is not optional
+
+ if (!workflowId) {
+ EventReporter.report('Detected ID-less workflow', {
+ level: 'info',
+ extra: { execution: fullExecutionData },
+ });
+ }
+
this.logger.info(
`Start job: ${job.id} (Workflow ID: ${workflowId} | Execution: ${executionId})`,
);
@@ -292,7 +306,6 @@ export class Worker extends BaseCommand {
this.logger.debug('Queue init complete');
await this.initOrchestration();
this.logger.debug('Orchestration init complete');
- await this.initQueue();
await Container.get(OrchestrationWorkerService).publishToEventLog(
new EventMessageGeneric({
diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts
index 7598ff788f922..093430326763d 100644
--- a/packages/cli/src/databases/repositories/execution.repository.ts
+++ b/packages/cli/src/databases/repositories/execution.repository.ts
@@ -220,7 +220,7 @@ export class ExecutionRepository extends Repository {
const { connections, nodes, name } = workflowData ?? {};
await this.executionDataRepository.insert({
executionId,
- workflowData: { connections, nodes, name },
+ workflowData: { connections, nodes, name, id: workflowData?.id },
data: stringify(data),
});
return String(executionId);
diff --git a/packages/cli/test/integration/commands/worker.cmd.test.ts b/packages/cli/test/integration/commands/worker.cmd.test.ts
index 3092e80f6eac2..ee5ac646d2d02 100644
--- a/packages/cli/test/integration/commands/worker.cmd.test.ts
+++ b/packages/cli/test/integration/commands/worker.cmd.test.ts
@@ -65,15 +65,15 @@ test('worker initializes all its components', async () => {
expect(worker.queueModeId).toBeDefined();
expect(worker.queueModeId).toContain('worker');
expect(worker.queueModeId.length).toBeGreaterThan(15);
- expect(worker.initLicense).toHaveBeenCalled();
- expect(worker.initBinaryDataService).toHaveBeenCalled();
- expect(worker.initExternalHooks).toHaveBeenCalled();
- expect(worker.initExternalSecrets).toHaveBeenCalled();
- expect(worker.initEventBus).toHaveBeenCalled();
- expect(worker.initOrchestration).toHaveBeenCalled();
- expect(OrchestrationHandlerWorkerService.prototype.initSubscriber).toHaveBeenCalled();
- expect(OrchestrationWorkerService.prototype.publishToEventLog).toHaveBeenCalled();
- expect(worker.initQueue).toHaveBeenCalled();
+ expect(worker.initLicense).toHaveBeenCalledTimes(1);
+ expect(worker.initBinaryDataService).toHaveBeenCalledTimes(1);
+ expect(worker.initExternalHooks).toHaveBeenCalledTimes(1);
+ expect(worker.initExternalSecrets).toHaveBeenCalledTimes(1);
+ expect(worker.initEventBus).toHaveBeenCalledTimes(1);
+ expect(worker.initOrchestration).toHaveBeenCalledTimes(1);
+ expect(OrchestrationHandlerWorkerService.prototype.initSubscriber).toHaveBeenCalledTimes(1);
+ expect(OrchestrationWorkerService.prototype.publishToEventLog).toHaveBeenCalledTimes(1);
+ expect(worker.initQueue).toHaveBeenCalledTimes(1);
jest.restoreAllMocks();
});
diff --git a/packages/cli/test/integration/database/repositories/execution.repository.test.ts b/packages/cli/test/integration/database/repositories/execution.repository.test.ts
index d16645367f66b..62cb57d2b8ac5 100644
--- a/packages/cli/test/integration/database/repositories/execution.repository.test.ts
+++ b/packages/cli/test/integration/database/repositories/execution.repository.test.ts
@@ -43,6 +43,7 @@ describe('ExecutionRepository', () => {
const executionDataRepo = Container.get(ExecutionDataRepository);
const executionData = await executionDataRepo.findOneBy({ executionId });
expect(executionData?.workflowData).toEqual({
+ id: workflow.id,
connections: workflow.connections,
nodes: workflow.nodes,
name: workflow.name,
diff --git a/packages/editor-ui/src/components/ExecutionsView/ExecutionsSidebar.vue b/packages/editor-ui/src/components/ExecutionsView/ExecutionsSidebar.vue
index d8c26cd5dcd85..2f6b482b73d60 100644
--- a/packages/editor-ui/src/components/ExecutionsView/ExecutionsSidebar.vue
+++ b/packages/editor-ui/src/components/ExecutionsView/ExecutionsSidebar.vue
@@ -120,10 +120,6 @@ export default defineComponent({
this.$router.go(-1);
}
},
- 'workflowsStore.activeWorkflowExecution'() {
- this.checkListSize();
- this.scrollToActiveCard();
- },
},
mounted() {
// On larger screens, we need to load more then first page of executions
diff --git a/packages/editor-ui/src/components/Node.vue b/packages/editor-ui/src/components/Node.vue
index 8fe9b64379e3f..07a0b273e2410 100644
--- a/packages/editor-ui/src/components/Node.vue
+++ b/packages/editor-ui/src/components/Node.vue
@@ -637,8 +637,7 @@ export default defineComponent({
// so we only update it when necessary (when node is mounted and when it's opened and closed (isActive))
try {
const nodeSubtitle =
- this.nodeHelpers.getNodeSubtitle(this.data, this.nodeType, this.getCurrentWorkflow()) ||
- '';
+ this.nodeHelpers.getNodeSubtitle(this.data, this.nodeType, this.workflow) || '';
this.nodeSubtitle = nodeSubtitle.includes(CUSTOM_API_CALL_KEY) ? '' : nodeSubtitle;
} catch (e) {
diff --git a/packages/editor-ui/src/composables/useNodeHelpers.ts b/packages/editor-ui/src/composables/useNodeHelpers.ts
index fda8a2fa35bc1..93b262381f047 100644
--- a/packages/editor-ui/src/composables/useNodeHelpers.ts
+++ b/packages/editor-ui/src/composables/useNodeHelpers.ts
@@ -420,7 +420,7 @@ export function useNodeHelpers() {
.getCredentialsByType(credentialTypeDescription.name)
.filter((credential: ICredentialsResponse) => {
const permissions = getCredentialPermissions(currentUser, credential);
- return permissions.read;
+ return permissions.use;
});
if (userCredentials === null) {
diff --git a/packages/editor-ui/src/hooks/cloud.ts b/packages/editor-ui/src/hooks/cloud.ts
index f4990cba6338e..4c68bc4d284fd 100644
--- a/packages/editor-ui/src/hooks/cloud.ts
+++ b/packages/editor-ui/src/hooks/cloud.ts
@@ -268,7 +268,7 @@ export const n8nCloudHooks: PartialDeep = {
dialogVisibleChanged: [
(_, meta) => {
const segmentStore = useSegment();
- const currentValue = meta.value.slice(1);
+ const currentValue = meta.value?.slice(1) ?? '';
let isValueDefault = false;
switch (typeof meta.parameter.default) {
diff --git a/packages/editor-ui/src/permissions.ts b/packages/editor-ui/src/permissions.ts
index dfbec10fb4530..639ced565978f 100644
--- a/packages/editor-ui/src/permissions.ts
+++ b/packages/editor-ui/src/permissions.ts
@@ -101,6 +101,10 @@ export const getCredentialPermissions = (user: IUser | null, credential: ICreden
test: (permissions) =>
hasPermission(['rbac'], { rbac: { scope: 'credential:delete' } }) || !!permissions.isOwner,
},
+ {
+ name: 'use',
+ test: (permissions) => !!permissions.isOwner || !!permissions.isSharee,
+ },
];
return parsePermissionsTable(user, table);
diff --git a/packages/editor-ui/src/plugins/i18n/index.ts b/packages/editor-ui/src/plugins/i18n/index.ts
index 28b27ea3383c0..712add0cd2361 100644
--- a/packages/editor-ui/src/plugins/i18n/index.ts
+++ b/packages/editor-ui/src/plugins/i18n/index.ts
@@ -23,6 +23,8 @@ export const i18nInstance = createI18n({
});
export class I18nClass {
+ private baseTextCache = new Map();
+
private get i18n() {
return i18nInstance.global;
}
@@ -50,11 +52,25 @@ export class I18nClass {
key: BaseTextKey,
options?: { adjustToNumber?: number; interpolate?: { [key: string]: string } },
): string {
+ // Create a unique cache key
+ const cacheKey = `${key}-${JSON.stringify(options)}`;
+
+ // Check if the result is already cached
+ if (this.baseTextCache.has(cacheKey)) {
+ return this.baseTextCache.get(cacheKey) ?? key;
+ }
+
+ let result: string;
if (options?.adjustToNumber !== undefined) {
- return this.i18n.tc(key, options.adjustToNumber, options?.interpolate).toString();
+ result = this.i18n.tc(key, options.adjustToNumber, options?.interpolate ?? {}).toString();
+ } else {
+ result = this.i18n.t(key, options?.interpolate ?? {}).toString();
}
- return this.i18n.t(key, options?.interpolate).toString();
+ // Store the result in the cache
+ this.baseTextCache.set(cacheKey, result);
+
+ return result;
}
/**
diff --git a/packages/editor-ui/src/plugins/jsplumb/N8nAddInputEndpointRenderer.ts b/packages/editor-ui/src/plugins/jsplumb/N8nAddInputEndpointRenderer.ts
index c7102d755219a..54d4d98695598 100644
--- a/packages/editor-ui/src/plugins/jsplumb/N8nAddInputEndpointRenderer.ts
+++ b/packages/editor-ui/src/plugins/jsplumb/N8nAddInputEndpointRenderer.ts
@@ -70,7 +70,6 @@ export const register = () => {
container.appendChild(unconnectedGroup);
container.appendChild(defaultGroup);
- endpointInstance.setupOverlays();
endpointInstance.setVisible(false);
return container;
diff --git a/packages/editor-ui/src/plugins/jsplumb/N8nAddInputEndpointType.ts b/packages/editor-ui/src/plugins/jsplumb/N8nAddInputEndpointType.ts
index d80697fe8d21a..4bd54ae486b58 100644
--- a/packages/editor-ui/src/plugins/jsplumb/N8nAddInputEndpointType.ts
+++ b/packages/editor-ui/src/plugins/jsplumb/N8nAddInputEndpointType.ts
@@ -33,11 +33,6 @@ export class N8nAddInputEndpoint extends EndpointRepresentation {
this.endpoint.getOverlays()[overlay].setVisible(visible);
});
-
this.setVisible(visible);
-
// Re-trigger the success state if label is set
if (visible && this.label) {
this.setSuccessOutput(this.label);
}
- this.instance.setSuspendDrawing(false);
}
setSuccessOutput(label: string) {
diff --git a/packages/editor-ui/src/stores/users.store.ts b/packages/editor-ui/src/stores/users.store.ts
index 312a5ec0db83e..6d20dda8e4611 100644
--- a/packages/editor-ui/src/stores/users.store.ts
+++ b/packages/editor-ui/src/stores/users.store.ts
@@ -95,7 +95,7 @@ export const useUsersStore = defineStore(STORES.USERS, {
return (resource: ICredentialsResponse): boolean => {
const permissions = getCredentialPermissions(this.currentUser, resource);
- return permissions.read;
+ return permissions.use;
};
},
},
diff --git a/packages/editor-ui/src/stores/workflows.store.ts b/packages/editor-ui/src/stores/workflows.store.ts
index 5818dd4f5098a..a217641863410 100644
--- a/packages/editor-ui/src/stores/workflows.store.ts
+++ b/packages/editor-ui/src/stores/workflows.store.ts
@@ -894,8 +894,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
},
};
}
-
- this.workflowExecutionPairedItemMappings = getPairedItemsMapping(this.workflowExecutionData);
},
resetAllNodesIssues(): boolean {
@@ -1130,7 +1128,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
};
}
this.workflowExecutionData.data!.resultData.runData[pushData.nodeName].push(pushData.data);
- this.workflowExecutionPairedItemMappings = getPairedItemsMapping(this.workflowExecutionData);
},
clearNodeExecutionData(nodeName: string): void {
if (!this.workflowExecutionData?.data) {
diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue
index 1ab3118ed2352..aa125f592aaac 100644
--- a/packages/editor-ui/src/views/NodeView.vue
+++ b/packages/editor-ui/src/views/NodeView.vue
@@ -75,6 +75,7 @@
@removeNode="(name) => removeNode(name, true)"
:key="`${stickyData.id}_sticky`"
:name="stickyData.name"
+ :workflow="currentWorkflowObject"
:isReadOnly="isReadOnlyRoute || readOnlyEnv"
:instance="instance"
:isActive="!!activeNode && activeNode.name === stickyData.name"
@@ -732,6 +733,7 @@ export default defineComponent({
showTriggerMissingTooltip: false,
workflowData: null as INewWorkflowData | null,
activeConnection: null as null | Connection,
+ isInsertingNodes: false,
isProductionExecutionPreview: false,
enterTimer: undefined as undefined | ReturnType,
exitTimer: undefined as undefined | ReturnType,
@@ -2735,15 +2737,6 @@ export default defineComponent({
});
},
);
- setTimeout(() => {
- NodeViewUtils.addConnectionTestData(
- info.source,
- info.target,
- info.connection?.connector?.hasOwnProperty('canvas')
- ? (info.connection.connector.canvas as HTMLElement)
- : undefined,
- );
- }, 0);
const endpointArrow = NodeViewUtils.getOverlay(
info.connection,
@@ -2763,14 +2756,44 @@ export default defineComponent({
if (!this.suspendRecordingDetachedConnections) {
this.historyStore.pushCommandToUndo(new AddConnectionCommand(connectionData));
}
- this.nodeHelpers.updateNodesInputIssues();
- this.resetEndpointsErrors();
+ // When we add multiple nodes, this event could be fired hundreds of times for large workflows.
+ // And because the updateNodesInputIssues() method is quite expensive, we only call it if not in insert mode
+ if (!this.isInsertingNodes) {
+ this.nodeHelpers.updateNodesInputIssues();
+ this.resetEndpointsErrors();
+ setTimeout(() => {
+ NodeViewUtils.addConnectionTestData(
+ info.source,
+ info.target,
+ info.connection?.connector?.hasOwnProperty('canvas')
+ ? (info.connection.connector.canvas as HTMLElement)
+ : undefined,
+ );
+ }, 0);
+ }
}
} catch (e) {
console.error(e);
}
},
+ addConectionsTestData() {
+ this.instance.connections.forEach((connection) => {
+ NodeViewUtils.addConnectionTestData(
+ connection.source,
+ connection.target,
+ connection?.connector?.hasOwnProperty('canvas')
+ ? (connection?.connector.canvas as HTMLElement)
+ : undefined,
+ );
+ });
+ },
onDragMove() {
+ const totalNodes = this.nodes.length;
+ void this.callDebounced('updateConnectionsOverlays', {
+ debounceTime: totalNodes > 20 ? 200 : 0,
+ });
+ },
+ updateConnectionsOverlays() {
this.instance?.connections.forEach((connection) => {
NodeViewUtils.showOrHideItemsLabel(connection);
NodeViewUtils.showOrHideMidpointArrow(connection);
@@ -3915,7 +3938,7 @@ export default defineComponent({
if (!nodes?.length) {
return;
}
-
+ this.isInsertingNodes = true;
// Before proceeding we must check if all nodes contain the `properties` attribute.
// Nodes are loaded without this information so we must make sure that all nodes
// being added have this information.
@@ -3973,60 +3996,64 @@ export default defineComponent({
// check and match credentials, apply new format if old is used
this.matchCredentials(node);
-
this.workflowsStore.addNode(node);
if (trackHistory) {
this.historyStore.pushCommandToUndo(new AddNodeCommand(node));
}
});
- // Wait for the node to be rendered
+ // Wait for the nodes to be rendered
await this.$nextTick();
- // Suspend drawing
this.instance?.setSuspendDrawing(true);
- // Load the connections
- if (connections !== undefined) {
- let connectionData;
- for (const sourceNode of Object.keys(connections)) {
- for (const type of Object.keys(connections[sourceNode])) {
- for (
- let sourceIndex = 0;
- sourceIndex < connections[sourceNode][type].length;
- sourceIndex++
- ) {
- const outwardConnections = connections[sourceNode][type][sourceIndex];
- if (!outwardConnections) {
- continue;
- }
+ if (connections) {
+ await this.addConnections(connections);
+ }
+ // Add the node issues at the end as the node-connections are required
+ this.nodeHelpers.refreshNodeIssues();
+ this.nodeHelpers.updateNodesInputIssues();
+ this.resetEndpointsErrors();
+ this.isInsertingNodes = false;
+
+ // Now it can draw again
+ this.instance?.setSuspendDrawing(false, true);
+ },
+ async addConnections(connections: IConnections) {
+ const batchedConnectionData: Array<[IConnection, IConnection]> = [];
+
+ for (const sourceNode in connections) {
+ for (const type in connections[sourceNode]) {
+ connections[sourceNode][type].forEach((outwardConnections, sourceIndex) => {
+ if (outwardConnections) {
outwardConnections.forEach((targetData) => {
- connectionData = [
- {
- node: sourceNode,
- type,
- index: sourceIndex,
- },
- {
- node: targetData.node,
- type: targetData.type,
- index: targetData.index,
- },
- ] as [IConnection, IConnection];
-
- this.__addConnection(connectionData);
+ batchedConnectionData.push([
+ { node: sourceNode, type, index: sourceIndex },
+ { node: targetData.node, type: targetData.type, index: targetData.index },
+ ]);
});
}
- }
+ });
}
}
- // Add the node issues at the end as the node-connections are required
- void this.nodeHelpers.refreshNodeIssues();
+ // Process the connections in batches
+ await this.processConnectionBatch(batchedConnectionData);
+ setTimeout(this.addConectionsTestData, 0);
+ },
- // Now it can draw again
- this.instance?.setSuspendDrawing(false, true);
+ async processConnectionBatch(batchedConnectionData: Array<[IConnection, IConnection]>) {
+ const batchSize = 100;
+
+ for (let i = 0; i < batchedConnectionData.length; i += batchSize) {
+ const batch = batchedConnectionData.slice(i, i + batchSize);
+
+ batch.forEach((connectionData) => {
+ this.__addConnection(connectionData);
+ });
+ }
},
+
async addNodesToWorkflow(data: IWorkflowDataUpdate): Promise {
// Because nodes with the same name maybe already exist, it could
// be needed that they have to be renamed. Also could it be possible
diff --git a/packages/workflow/src/Workflow.ts b/packages/workflow/src/Workflow.ts
index 6ec06db20cadd..f09256d8ca1be 100644
--- a/packages/workflow/src/Workflow.ts
+++ b/packages/workflow/src/Workflow.ts
@@ -52,6 +52,7 @@ import { RoutingNode } from './RoutingNode';
import { Expression } from './Expression';
import { NODES_WITH_RENAMABLE_CONTENT } from './Constants';
import { ApplicationError } from './errors/application.error';
+import * as EventReporter from './ErrorReporterProxy';
function dedupe(arr: T[]): T[] {
return [...new Set(arr)];
@@ -94,7 +95,14 @@ export class Workflow {
settings?: IWorkflowSettings;
pinData?: IPinData;
}) {
- this.id = parameters.id as string;
+ if (!parameters.id) {
+ EventReporter.report('Detected ID-less workflow', {
+ level: 'info',
+ extra: { parameters },
+ });
+ }
+
+ this.id = parameters.id as string; // @tech_debt Ensure this is not optional
this.name = parameters.name;
this.nodeTypes = parameters.nodeTypes;
this.pinData = parameters.pinData;