first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
import { defineConfig } from 'eslint/config';
import { baseConfig } from '@n8n/eslint-config/base';
export default defineConfig(
baseConfig,
{
rules: {
'unicorn/filename-case': ['error', { case: 'kebabCase' }],
// TODO: Remove this
'@typescript-eslint/naming-convention': 'warn',
'@typescript-eslint/no-unsafe-member-access': 'warn',
'@typescript-eslint/no-unsafe-assignment': 'warn',
'@typescript-eslint/prefer-nullish-coalescing': 'warn',
'@typescript-eslint/unbound-method': 'warn',
'@typescript-eslint/no-base-to-string': 'warn',
'@typescript-eslint/require-await': 'warn',
'@typescript-eslint/no-unsafe-call': 'warn',
'@typescript-eslint/no-unsafe-function-type': 'warn',
'@typescript-eslint/no-empty-object-type': 'warn',
'@typescript-eslint/no-restricted-types': 'warn',
'no-useless-escape': 'warn',
'no-empty': 'warn',
},
},
{
files: ['**/*.test.ts', '**/__tests__/**/*.ts'],
rules: {
'@typescript-eslint/no-unsafe-return': 'warn',
},
},
{
files: ['./src/migrations/**/*.ts'],
rules: {
'unicorn/filename-case': 'off',
},
},
);
+10
View File
@@ -0,0 +1,10 @@
const baseConfig = require('../../../jest.config');
/** @type {import('jest').Config} */
module.exports = {
...baseConfig,
transform: {
...baseConfig.transform,
'^.+\\.ts$': ['ts-jest', { isolatedModules: false }],
},
};
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@n8n/db",
"version": "1.11.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
"typecheck": "tsc --noEmit",
"build": "tsc -p tsconfig.build.json",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:unit": "jest",
"test:dev": "jest --watch"
},
"main": "dist/index.js",
"module": "src/index.ts",
"types": "dist/index.d.ts",
"files": [
"dist/**/*"
],
"dependencies": {
"@n8n/api-types": "workspace:*",
"@n8n/backend-common": "workspace:*",
"@n8n/config": "workspace:*",
"@n8n/constants": "workspace:*",
"@n8n/decorators": "workspace:*",
"@n8n/di": "workspace:*",
"@n8n/permissions": "workspace:*",
"@n8n/utils": "workspace:*",
"@n8n/typeorm": "catalog:",
"class-validator": "0.14.0",
"flatted": "catalog:",
"lodash": "catalog:",
"n8n-core": "workspace:*",
"n8n-workflow": "workspace:*",
"nanoid": "catalog:",
"p-lazy": "3.1.0",
"reflect-metadata": "catalog:",
"uuid": "catalog:",
"xss": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"@types/lodash": "catalog:",
"express": "5.1.0"
}
}
@@ -0,0 +1,164 @@
import type { ModuleRegistry } from '@n8n/backend-common';
import type { GlobalConfig, InstanceSettingsConfig } from '@n8n/config';
import { mock } from 'jest-mock-extended';
import path from 'path';
import { postgresMigrations } from '../../migrations/postgresdb';
import { sqliteMigrations } from '../../migrations/sqlite';
import { DbConnectionOptions } from '../db-connection-options';
describe('DbConnectionOptions', () => {
const dbConfig = mock<GlobalConfig['database']>({
tablePrefix: 'test_prefix_',
logging: {
enabled: false,
maxQueryExecutionTime: 0,
},
});
const n8nFolder = '/test/n8n';
const instanceSettingsConfig = mock<InstanceSettingsConfig>({ n8nFolder });
const moduleRegistry = mock<ModuleRegistry>({ entities: [] });
const dbConnectionOptions = new DbConnectionOptions(
dbConfig,
instanceSettingsConfig,
moduleRegistry,
);
beforeEach(() => jest.resetAllMocks());
const commonOptions = {
entityPrefix: 'test_prefix_',
entities: expect.any(Array),
subscribers: expect.any(Array),
migrationsTableName: 'test_prefix_migrations',
migrationsRun: false,
synchronize: false,
maxQueryExecutionTime: 0,
logging: false,
};
describe('getOptions', () => {
it('should throw an error for unsupported database types', () => {
// @ts-expect-error invalid type
dbConfig.type = 'unsupported';
expect(() => dbConnectionOptions.getOptions()).toThrow(
'Database type currently not supported',
);
});
describe('for SQLite', () => {
beforeEach(() => {
dbConfig.type = 'sqlite';
dbConfig.sqlite = {
database: 'test.sqlite',
poolSize: 3,
executeVacuumOnStartup: false,
};
});
it('should return SQLite pooled connection options when type is sqlite', () => {
const result = dbConnectionOptions.getOptions();
expect(result).toEqual({
type: 'sqlite-pooled',
poolSize: 3,
enableWAL: true,
acquireTimeout: 60_000,
destroyTimeout: 5_000,
...commonOptions,
database: path.resolve(n8nFolder, 'test.sqlite'),
migrations: sqliteMigrations,
});
});
});
describe('PostgreSQL', () => {
beforeEach(() => {
dbConfig.type = 'postgresdb';
dbConfig.postgresdb = {
database: 'test_db',
host: 'localhost',
port: 5432,
user: 'postgres',
password: 'password',
schema: 'public',
poolSize: 2,
connectionTimeoutMs: 20000,
idleTimeoutMs: 30000,
statementTimeoutMs: 300000,
ssl: {
enabled: false,
ca: '',
cert: '',
key: '',
rejectUnauthorized: true,
},
};
});
it('should return PostgreSQL connection options when type is postgresdb', () => {
const result = dbConnectionOptions.getOptions();
expect(result).toEqual({
type: 'postgres',
...commonOptions,
database: 'test_db',
host: 'localhost',
port: 5432,
username: 'postgres',
password: 'password',
schema: 'public',
poolSize: 2,
migrations: postgresMigrations,
connectTimeoutMS: 20000,
statementTimeout: 300_000,
ssl: false,
extra: {
idleTimeoutMillis: 30000,
},
});
});
it('should configure SSL options for PostgreSQL when SSL settings are provided', () => {
const ssl = {
ca: 'ca-content',
cert: 'cert-content',
key: 'key-content',
rejectUnauthorized: false,
};
dbConfig.postgresdb.ssl = { enabled: true, ...ssl };
const result = dbConnectionOptions.getOptions();
expect(result).toMatchObject({ ssl });
});
});
describe('logging', () => {
beforeEach(() => {
dbConfig.type = 'sqlite';
dbConfig.sqlite = mock<GlobalConfig['database']['sqlite']>({ database: 'test.sqlite' });
});
it('should not configure logging by default', () => {
const result = dbConnectionOptions.getOptions();
expect(result.logging).toBe(false);
});
it('should configure logging when it is enabled', () => {
dbConfig.logging = {
enabled: true,
options: 'all',
maxQueryExecutionTime: 1000,
};
const result = dbConnectionOptions.getOptions();
expect(result.logging).toBe('all');
expect(result.maxQueryExecutionTime).toBe(1000);
});
});
});
});
@@ -0,0 +1,214 @@
/* eslint-disable @typescript-eslint/unbound-method */
import type { Logger } from '@n8n/backend-common';
import type { DatabaseConfig } from '@n8n/config';
import { DataSource, type DataSourceOptions } from '@n8n/typeorm';
import { mock, mockDeep } from 'jest-mock-extended';
import type { ErrorReporter } from 'n8n-core';
import { DbConnectionTimeoutError } from 'n8n-workflow';
import * as migrationHelper from '../../migrations/migration-helpers';
import type { Migration } from '../../migrations/migration-types';
import { DbConnection } from '../db-connection';
import type { DbConnectionOptions } from '../db-connection-options';
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
jest.mock('@n8n/typeorm', () => ({
// eslint-disable-next-line @typescript-eslint/naming-convention
DataSource: jest.fn(),
...jest.requireActual('@n8n/typeorm'),
}));
describe('DbConnection', () => {
let dbConnection: DbConnection;
const migrations = [{ name: 'TestMigration1' }, { name: 'TestMigration2' }] as Migration[];
const errorReporter = mock<ErrorReporter>();
const databaseConfig = mock<DatabaseConfig>();
const logger = mock<Logger>();
const dataSource = mockDeep<DataSource>({ options: { migrations } });
const connectionOptions = mockDeep<DbConnectionOptions>();
const postgresOptions: DataSourceOptions = {
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'user',
password: 'password',
database: 'n8n',
migrations,
};
beforeEach(() => {
jest.resetAllMocks();
connectionOptions.getOptions.mockReturnValue(postgresOptions);
(DataSource as jest.Mock) = jest.fn().mockImplementation(() => dataSource);
dbConnection = new DbConnection(errorReporter, connectionOptions, databaseConfig, logger);
});
describe('init', () => {
it('should initialize the data source', async () => {
dataSource.initialize.mockResolvedValue(dataSource);
await dbConnection.init();
expect(dataSource.initialize).toHaveBeenCalled();
expect(dbConnection.connectionState.connected).toBe(true);
});
it('should not reinitialize if already connected', async () => {
dataSource.initialize.mockResolvedValue(dataSource);
dbConnection.connectionState.connected = true;
await dbConnection.init();
expect(dataSource.initialize).not.toHaveBeenCalled();
});
it('should wrap postgres connection timeout errors', async () => {
const originalError = new Error('Connection terminated due to connection timeout');
dataSource.initialize.mockRejectedValue(originalError);
connectionOptions.getOptions.mockReturnValue({
type: 'postgres',
connectTimeoutMS: 10000,
});
await expect(dbConnection.init()).rejects.toThrow(DbConnectionTimeoutError);
});
it('should rethrow other errors', async () => {
// Arrange
const error = new Error('Some other error');
dataSource.initialize.mockRejectedValue(error);
// Act & Assert
await expect(dbConnection.init()).rejects.toThrow('Some other error');
});
});
describe('migrate', () => {
it('should wrap migrations and run them', async () => {
dataSource.runMigrations.mockResolvedValue([]);
const wrapMigrationSpy = jest.spyOn(migrationHelper, 'wrapMigration').mockImplementation();
expect(dataSource.runMigrations).not.toHaveBeenCalled();
expect(dbConnection.connectionState.migrated).toBe(false);
await dbConnection.migrate();
expect(wrapMigrationSpy).toHaveBeenCalledTimes(2);
expect(dataSource.runMigrations).toHaveBeenCalledWith({ transaction: 'each' });
expect(dbConnection.connectionState.migrated).toBe(true);
});
});
describe('close', () => {
it('should clear the ping timer', async () => {
const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
// @ts-expect-error private property
dbConnection.pingTimer = setTimeout(() => {}, 1000);
await dbConnection.close();
expect(clearTimeoutSpy).toHaveBeenCalled();
// @ts-expect-error private property
expect(dbConnection.pingTimer).toBeUndefined();
});
it('should destroy the data source if initialized', async () => {
// @ts-expect-error readonly property
dataSource.isInitialized = true;
await dbConnection.close();
expect(dataSource.destroy).toHaveBeenCalled();
});
it('should not try to destroy the data source if not initialized', async () => {
// @ts-expect-error readonly property
dataSource.isInitialized = false;
await dbConnection.close();
expect(dataSource.destroy).not.toHaveBeenCalled();
});
});
describe('ping', () => {
it('should update connection state on successful ping', async () => {
// @ts-expect-error readonly property
dataSource.isInitialized = true;
// eslint-disable-next-line @typescript-eslint/naming-convention
dataSource.query.mockResolvedValue([{ '1': 1 }]);
dbConnection.connectionState.connected = false;
// @ts-expect-error private property
await dbConnection.ping();
expect(dataSource.query).toHaveBeenCalledWith('SELECT 1');
expect(dbConnection.connectionState.connected).toBe(true);
});
it('should report errors on failed ping', async () => {
// @ts-expect-error readonly property
dataSource.isInitialized = true;
const error = new Error('Connection error');
dataSource.query.mockRejectedValue(error);
// @ts-expect-error private property
await dbConnection.ping();
expect(errorReporter.error).toHaveBeenCalledWith(error);
});
it('should schedule next ping after execution', async () => {
// @ts-expect-error readonly property
dataSource.isInitialized = true;
// eslint-disable-next-line @typescript-eslint/naming-convention
dataSource.query.mockResolvedValue([{ '1': 1 }]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const scheduleNextPingSpy = jest.spyOn(dbConnection as any, 'scheduleNextPing');
// @ts-expect-error private property
await dbConnection.ping();
expect(scheduleNextPingSpy).toHaveBeenCalled();
});
it('should not query if data source is not initialized', async () => {
// @ts-expect-error readonly property
dataSource.isInitialized = false;
// @ts-expect-error private property
await dbConnection.ping();
expect(dataSource.query).not.toHaveBeenCalled();
});
it('should execute ping on schedule', () => {
jest.useFakeTimers();
try {
// ARRANGE
dbConnection = new DbConnection(
errorReporter,
connectionOptions,
mock<DatabaseConfig>({
pingIntervalSeconds: 1,
}),
logger,
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pingSpy = jest.spyOn(dbConnection as any, 'ping');
// @ts-expect-error private property
dbConnection.scheduleNextPing();
jest.advanceTimersByTime(1000);
expect(pingSpy).toHaveBeenCalled();
} finally {
jest.useRealTimers();
}
});
});
});
@@ -0,0 +1,118 @@
import { ModuleRegistry } from '@n8n/backend-common';
import { DatabaseConfig, InstanceSettingsConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import type { DataSourceOptions, LoggerOptions } from '@n8n/typeorm';
import type { PostgresConnectionOptions } from '@n8n/typeorm/driver/postgres/PostgresConnectionOptions';
import type { SqlitePooledConnectionOptions } from '@n8n/typeorm/driver/sqlite-pooled/SqlitePooledConnectionOptions';
import { UserError } from 'n8n-workflow';
import type { TlsOptions } from 'node:tls';
import path from 'path';
import { entities } from '../entities';
import { postgresMigrations } from '../migrations/postgresdb';
import { sqliteMigrations } from '../migrations/sqlite';
import { subscribers } from '../subscribers';
@Service()
export class DbConnectionOptions {
constructor(
private readonly config: DatabaseConfig,
private readonly instanceSettingsConfig: InstanceSettingsConfig,
private readonly moduleRegistry: ModuleRegistry,
) {}
getPostgresOverrides() {
return {
database: this.config.postgresdb.database,
host: this.config.postgresdb.host,
port: this.config.postgresdb.port,
username: this.config.postgresdb.user,
password: this.config.postgresdb.password,
};
}
getOptions(): DataSourceOptions {
const { type: dbType } = this.config;
switch (dbType) {
case 'sqlite':
return this.getSqliteConnectionOptions();
case 'postgresdb':
return this.getPostgresConnectionOptions();
default:
throw new UserError('Database type currently not supported', { extra: { dbType } });
}
}
private getCommonOptions() {
const { tablePrefix: entityPrefix, logging: loggingConfig } = this.config;
let loggingOption: LoggerOptions = loggingConfig.enabled;
if (loggingOption) {
const optionsString = loggingConfig.options.replace(/\s+/g, '');
if (optionsString === 'all') {
loggingOption = optionsString;
} else {
loggingOption = optionsString.split(',') as LoggerOptions;
}
}
return {
entityPrefix,
entities: [...Object.values(entities), ...this.moduleRegistry.entities],
subscribers: Object.values(subscribers),
migrationsTableName: `${entityPrefix}migrations`,
migrationsRun: false,
synchronize: false,
maxQueryExecutionTime: loggingConfig.maxQueryExecutionTime,
logging: loggingOption,
};
}
private getSqliteConnectionOptions(): SqlitePooledConnectionOptions {
const { sqlite: sqliteConfig } = this.config;
const { n8nFolder } = this.instanceSettingsConfig;
return {
type: 'sqlite-pooled',
poolSize: sqliteConfig.poolSize,
enableWAL: true,
acquireTimeout: 60_000,
destroyTimeout: 5_000,
...this.getCommonOptions(),
database: path.resolve(n8nFolder, sqliteConfig.database),
migrations: sqliteMigrations,
};
}
private getPostgresConnectionOptions(): PostgresConnectionOptions {
const { postgresdb: postgresConfig } = this.config;
const {
ssl: { ca: sslCa, cert: sslCert, key: sslKey, rejectUnauthorized: sslRejectUnauthorized },
} = postgresConfig;
let ssl: TlsOptions | boolean = postgresConfig.ssl.enabled;
if (sslCa !== '' || sslCert !== '' || sslKey !== '' || !sslRejectUnauthorized) {
ssl = {
ca: sslCa || undefined,
cert: sslCert || undefined,
key: sslKey || undefined,
rejectUnauthorized: sslRejectUnauthorized,
};
}
return {
type: 'postgres',
...this.getCommonOptions(),
...this.getPostgresOverrides(),
schema: postgresConfig.schema,
poolSize: postgresConfig.poolSize,
migrations: postgresMigrations,
connectTimeoutMS: postgresConfig.connectionTimeoutMs,
statementTimeout: postgresConfig.statementTimeoutMs,
ssl,
extra: {
idleTimeoutMillis: postgresConfig.idleTimeoutMs,
},
};
}
}
@@ -0,0 +1,130 @@
import { inTest, Logger } from '@n8n/backend-common';
import { DatabaseConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import { Memoized } from '@n8n/decorators';
import { Container, Service } from '@n8n/di';
import { DataSource } from '@n8n/typeorm';
import { ErrorReporter } from 'n8n-core';
import { DbConnectionTimeoutError, ensureError, OperationalError } from 'n8n-workflow';
import { setTimeout as setTimeoutP } from 'timers/promises';
import { DbConnectionOptions } from './db-connection-options';
import { wrapMigration } from '../migrations/migration-helpers';
import type { Migration } from '../migrations/migration-types';
type ConnectionState = {
connected: boolean;
migrated: boolean;
};
@Service()
export class DbConnection {
private dataSource: DataSource;
private pingTimer: NodeJS.Timeout | undefined;
timeout: number;
readonly connectionState: ConnectionState = {
connected: false,
migrated: false,
};
constructor(
private readonly errorReporter: ErrorReporter,
private readonly connectionOptions: DbConnectionOptions,
private readonly databaseConfig: DatabaseConfig,
private readonly logger: Logger,
) {
this.dataSource = new DataSource(this.options);
Container.set(DataSource, this.dataSource);
this.timeout = process.env.N8N_DB_PING_TIMEOUT
? Number.parseInt(process.env.N8N_DB_PING_TIMEOUT)
: 5000;
}
@Memoized
get options() {
return this.connectionOptions.getOptions();
}
async init(): Promise<void> {
const { connectionState, options } = this;
if (connectionState.connected) return;
try {
await this.dataSource.initialize();
} catch (e) {
let error = ensureError(e);
if (
options.type === 'postgres' &&
error.message === 'Connection terminated due to connection timeout'
) {
error = new DbConnectionTimeoutError({
cause: error,
configuredTimeoutInMs: options.connectTimeoutMS!,
});
}
throw error;
}
connectionState.connected = true;
if (!inTest) this.scheduleNextPing();
}
async migrate() {
const { dataSource, connectionState } = this;
(dataSource.options.migrations as Migration[]).forEach(wrapMigration);
await dataSource.runMigrations({ transaction: 'each' });
connectionState.migrated = true;
}
async close() {
if (this.pingTimer) {
clearTimeout(this.pingTimer);
this.pingTimer = undefined;
}
if (this.dataSource.isInitialized) {
await this.dataSource.destroy();
this.connectionState.connected = false;
}
}
/** Ping DB connection every `pingIntervalSeconds` seconds to check if it is still alive. */
private scheduleNextPing() {
this.pingTimer = setTimeout(
async () => await this.ping(),
this.databaseConfig.pingIntervalSeconds * Time.seconds.toMilliseconds,
);
}
private async ping() {
if (!this.dataSource.isInitialized) return;
const abortController = new AbortController();
try {
await Promise.race([
this.dataSource.query('SELECT 1'),
setTimeoutP(this.timeout, undefined, { signal: abortController.signal }).then(() => {
throw new OperationalError('Database connection timed out');
}),
]);
if (!this.connectionState.connected) {
this.logger.info('Database connection recovered');
}
this.connectionState.connected = true;
return;
} catch (error) {
this.connectionState.connected = false;
if (error instanceof OperationalError) {
this.logger.warn(error.message);
} else {
this.errorReporter.error(error);
}
} finally {
abortController.abort();
this.scheduleNextPing();
}
}
}
+76
View File
@@ -0,0 +1,76 @@
import {
PROJECT_ADMIN_ROLE_SLUG,
PROJECT_EDITOR_ROLE_SLUG,
PROJECT_OWNER_ROLE_SLUG,
PROJECT_VIEWER_ROLE_SLUG,
PROJECT_CHAT_USER_ROLE_SLUG,
ALL_ROLES,
type ProjectRole,
type GlobalRole,
type Role as RoleDTO,
} from '@n8n/permissions';
import type { Role } from 'entities';
export function builtInRoleToRoleObject(
role: RoleDTO,
roleType: 'global' | 'project' | 'workflow' | 'credential',
): Role {
return {
slug: role.slug,
displayName: role.displayName,
scopes: role.scopes.map((scope) => {
return {
slug: scope,
displayName: scope,
description: null,
};
}),
systemRole: true,
roleType,
description: role.description,
} as Role;
}
function toRoleMap(allRoles: Role[]): Record<string, Role> {
return allRoles.reduce(
(acc, role) => {
acc[role.slug] = role;
return acc;
},
{} as Record<string, Role>,
);
}
export const ALL_BUILTIN_ROLES = toRoleMap([
...ALL_ROLES.global.map((role) => builtInRoleToRoleObject(role, 'global')),
...ALL_ROLES.project.map((role) => builtInRoleToRoleObject(role, 'project')),
...ALL_ROLES.credential.map((role) => builtInRoleToRoleObject(role, 'credential')),
...ALL_ROLES.workflow.map((role) => builtInRoleToRoleObject(role, 'workflow')),
]);
export const GLOBAL_OWNER_ROLE = ALL_BUILTIN_ROLES['global:owner'];
export const GLOBAL_ADMIN_ROLE = ALL_BUILTIN_ROLES['global:admin'];
export const GLOBAL_MEMBER_ROLE = ALL_BUILTIN_ROLES['global:member'];
export const GLOBAL_CHAT_USER_ROLE = ALL_BUILTIN_ROLES['global:chatUser'];
export const PROJECT_OWNER_ROLE = ALL_BUILTIN_ROLES[PROJECT_OWNER_ROLE_SLUG];
export const PROJECT_ADMIN_ROLE = ALL_BUILTIN_ROLES[PROJECT_ADMIN_ROLE_SLUG];
export const PROJECT_EDITOR_ROLE = ALL_BUILTIN_ROLES[PROJECT_EDITOR_ROLE_SLUG];
export const PROJECT_VIEWER_ROLE = ALL_BUILTIN_ROLES[PROJECT_VIEWER_ROLE_SLUG];
export const PROJECT_CHAT_USER_ROLE = ALL_BUILTIN_ROLES[PROJECT_CHAT_USER_ROLE_SLUG];
export const GLOBAL_ROLES: Record<GlobalRole, Role> = {
'global:owner': GLOBAL_OWNER_ROLE,
'global:admin': GLOBAL_ADMIN_ROLE,
'global:member': GLOBAL_MEMBER_ROLE,
'global:chatUser': GLOBAL_CHAT_USER_ROLE,
};
export const PROJECT_ROLES: Record<ProjectRole, Role> = {
[PROJECT_OWNER_ROLE_SLUG]: PROJECT_OWNER_ROLE,
[PROJECT_ADMIN_ROLE_SLUG]: PROJECT_ADMIN_ROLE,
[PROJECT_EDITOR_ROLE_SLUG]: PROJECT_EDITOR_ROLE,
[PROJECT_VIEWER_ROLE_SLUG]: PROJECT_VIEWER_ROLE,
[PROJECT_CHAT_USER_ROLE_SLUG]: PROJECT_CHAT_USER_ROLE,
};
@@ -0,0 +1,40 @@
import { isAuthProviderType } from '../types-db';
describe('types-db', () => {
describe('isAuthProviderType', () => {
it.each(['ldap', 'email', 'saml', 'oidc'])(
'should return true for valid "%s" auth provider types',
(provider) => {
expect(isAuthProviderType(provider)).toBe(true);
},
);
it.each([
'google',
'facebook',
'github',
'oauth2',
'jwt',
'basic',
'',
'LDAP', // case sensitive
'OIDC',
'Email',
])('should return false for invalid "%s" auth provider types', (provider) => {
expect(isAuthProviderType(provider)).toBe(false);
});
it.each([null, undefined, 123, true, false, {}, [], { type: 'oidc' }])(
'should return false for non-string value "%s"',
(value) => {
expect(isAuthProviderType(value as string)).toBe(false);
},
);
it('should handle edge cases', () => {
expect(isAuthProviderType(' oidc ')).toBe(false); // whitespace
expect(isAuthProviderType('oidc\n')).toBe(false); // newline
expect(isAuthProviderType('oidc\t')).toBe(false); // tab
});
});
});
@@ -0,0 +1,184 @@
import { AuthIdentity } from '../auth-identity';
import { type Role } from '../role';
import { User } from '../user';
describe('User Entity', () => {
describe('computeIsPending', () => {
const createUser = (overrides: Partial<User> = {}) => {
const user = new User();
user.password = null;
user.role = { slug: 'global:member' } as Role;
user.authIdentities = [];
return Object.assign(user, overrides);
};
const createAuthIdentity = (providerType: 'email' | 'saml' | 'oidc' | 'ldap'): AuthIdentity => {
const identity = new AuthIdentity();
identity.providerType = providerType;
identity.providerId = 'test-provider-id';
return identity;
};
it('should be pending when password is null and no auth identities', () => {
const user = createUser();
user.computeIsPending();
expect(user.isPending).toBe(true);
});
it('should NOT be pending when password is set', () => {
const user = createUser({ password: 'hashed-password' });
user.computeIsPending();
expect(user.isPending).toBe(false);
});
it('should NOT be pending when user is global owner (even without password)', () => {
const user = createUser({ role: { slug: 'global:owner' } as Role });
user.computeIsPending();
expect(user.isPending).toBe(false);
});
it('should NOT be pending when user has SAML auth identity', () => {
const user = createUser({ authIdentities: [createAuthIdentity('saml')] });
user.computeIsPending();
expect(user.isPending).toBe(false);
});
it('should NOT be pending when user has OIDC auth identity', () => {
const user = createUser({ authIdentities: [createAuthIdentity('oidc')] });
user.computeIsPending();
expect(user.isPending).toBe(false);
});
it('should NOT be pending when user has LDAP auth identity', () => {
const user = createUser({ authIdentities: [createAuthIdentity('ldap')] });
user.computeIsPending();
expect(user.isPending).toBe(false);
});
it('should be pending when user only has email auth identity (no password)', () => {
const user = createUser({ authIdentities: [createAuthIdentity('email')] });
user.computeIsPending();
expect(user.isPending).toBe(true);
});
it('should handle undefined authIdentities gracefully', () => {
const user = createUser();
user.authIdentities = undefined as unknown as AuthIdentity[];
user.computeIsPending();
expect(user.isPending).toBe(true);
});
});
describe('JSON.stringify', () => {
it('should not serialize sensitive data', () => {
const user = Object.assign(new User(), {
email: 'test@example.com',
firstName: 'Don',
lastName: 'Joe',
password: '123456789',
});
expect(JSON.stringify(user)).toEqual(
'{"email":"test@example.com","firstName":"Don","lastName":"Joe"}',
);
});
});
describe('createPersonalProjectName', () => {
test.each([
['Nathan', 'Nathaniel', 'nathan@nathaniel.n8n', 'Nathan Nathaniel <nathan@nathaniel.n8n>'],
[undefined, 'Nathaniel', 'nathan@nathaniel.n8n', '<nathan@nathaniel.n8n>'],
['Nathan', undefined, 'nathan@nathaniel.n8n', '<nathan@nathaniel.n8n>'],
[undefined, undefined, 'nathan@nathaniel.n8n', '<nathan@nathaniel.n8n>'],
[undefined, undefined, undefined, 'Unnamed Project'],
['Nathan', 'Nathaniel', undefined, 'Unnamed Project'],
])(
'given fistName: %s, lastName: %s and email: %s this gives the projectName: "%s"',
async (firstName, lastName, email, projectName) => {
const user = new User();
Object.assign(user, { firstName, lastName, email });
expect(user.createPersonalProjectName()).toBe(projectName);
},
);
});
describe('preUpsertHook email validation', () => {
describe('valid email scenarios', () => {
it('should allow null email', () => {
const user = new User();
(user as { email: string | null }).email = null;
expect(() => user.preUpsertHook()).not.toThrow();
expect(user.email).toBeNull();
});
it('should allow undefined email', () => {
const user = new User();
(user as { email: string | undefined }).email = undefined;
expect(() => user.preUpsertHook()).not.toThrow();
expect(user.email).toBeNull();
});
it('should lowercase valid emails', () => {
const user = new User();
user.email = 'TEST@EXAMPLE.COM';
expect(() => user.preUpsertHook()).not.toThrow();
expect(user.email).toBe('test@example.com');
});
it('should accept standard valid email formats', () => {
const validEmails = [
'test@example.com',
'user.name@example.com',
'user+tag@example.com',
'user123@example123.com',
'test@sub.example.com',
'a@b.co',
];
validEmails.forEach((email) => {
const user = new User();
user.email = email;
expect(() => user.preUpsertHook()).not.toThrow();
expect(user.email).toBe(email.toLowerCase());
});
});
});
describe('invalid email scenarios', () => {
it.each([
['test..email@example.com'],
['test@'],
['@example.com'],
['test@@example.com'],
['test @example.com'],
['test@ example.com'],
['.test@example.com'],
['test.@example.com'],
['test@example.'],
['test@.example.com'],
['test<>@example.com'],
['test[]@example.com'],
['test()@example.com'],
['test,@example.com'],
['test;@example.com'],
['test:@example.com'],
['test"@example.com'],
['test\\@example.com'],
[''],
[' '],
['a'.repeat(300) + '@invalid'],
])('should throw Error for invalid email <%s>', (email) => {
const user = new User();
user.email = email;
expect(() => user.preUpsertHook()).toThrow(Error);
expect(() => user.preUpsertHook()).toThrow(
`Cannot save user <${email}>: Provided email is invalid`,
);
});
});
});
});
@@ -0,0 +1,102 @@
import { GlobalConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import type { ColumnOptions } from '@n8n/typeorm';
import {
BeforeInsert,
BeforeUpdate,
Column,
CreateDateColumn,
PrimaryColumn,
UpdateDateColumn,
} from '@n8n/typeorm';
import { generateNanoId } from '@n8n/utils';
import type { Class } from 'n8n-core';
export const { type: dbType } = Container.get(GlobalConfig).database;
const timestampSyntax = {
sqlite: "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')",
postgresdb: 'CURRENT_TIMESTAMP(3)',
}[dbType];
export const jsonColumnType = dbType === 'sqlite' ? 'simple-json' : 'json';
export const datetimeColumnType = dbType === 'postgresdb' ? 'timestamptz' : 'datetime';
const binaryColumnTypeMap = {
sqlite: 'blob',
postgresdb: 'bytea',
} as const;
const binaryColumnType = binaryColumnTypeMap[dbType];
export function JsonColumn(options?: Omit<ColumnOptions, 'type'>) {
return Column({
...options,
type: jsonColumnType,
});
}
export function DateTimeColumn(options?: Omit<ColumnOptions, 'type'>) {
return Column({
...options,
type: datetimeColumnType,
});
}
export function BinaryColumn(options?: Omit<ColumnOptions, 'type'>) {
return Column({
...options,
type: binaryColumnType,
});
}
const tsColumnOptions: ColumnOptions = {
precision: 3,
default: () => timestampSyntax,
type: datetimeColumnType,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function mixinStringId<T extends Class<{}, any[]>>(base: T) {
class Derived extends base {
@PrimaryColumn('varchar')
id: string;
@BeforeInsert()
generateId() {
if (!this.id) {
this.id = generateNanoId();
}
}
}
return Derived;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function mixinUpdatedAt<T extends Class<{}, any[]>>(base: T) {
class Derived extends base {
@UpdateDateColumn(tsColumnOptions)
updatedAt: Date;
@BeforeUpdate()
setUpdateDate(): void {
this.updatedAt = new Date();
}
}
return Derived;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function mixinCreatedAt<T extends Class<{}, any[]>>(base: T) {
class Derived extends base {
@CreateDateColumn(tsColumnOptions)
createdAt: Date;
}
return Derived;
}
class BaseEntity {}
export const WithStringId = mixinStringId(BaseEntity);
export const WithCreatedAt = mixinCreatedAt(BaseEntity);
export const WithUpdatedAt = mixinUpdatedAt(BaseEntity);
export const WithTimestamps = mixinCreatedAt(mixinUpdatedAt(BaseEntity));
export const WithTimestampsAndStringId = mixinStringId(WithTimestamps);
@@ -0,0 +1,21 @@
import { Column, Entity, Index, ManyToMany, OneToMany } from '@n8n/typeorm';
import { IsString, Length } from 'class-validator';
import { WithTimestampsAndStringId } from './abstract-entity';
import type { AnnotationTagMapping } from './annotation-tag-mapping.ee';
import type { ExecutionAnnotation } from './execution-annotation.ee';
@Entity()
export class AnnotationTagEntity extends WithTimestampsAndStringId {
@Column({ length: 24 })
@Index({ unique: true })
@IsString({ message: 'Tag name must be of type string.' })
@Length(1, 24, { message: 'Tag name must be $constraint1 to $constraint2 characters long.' })
name: string;
@ManyToMany('ExecutionAnnotation', 'tags')
annotations: ExecutionAnnotation[];
@OneToMany('AnnotationTagMapping', 'tags')
annotationMappings: AnnotationTagMapping[];
}
@@ -0,0 +1,24 @@
import { Entity, JoinColumn, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import type { AnnotationTagEntity } from './annotation-tag-entity.ee';
import type { ExecutionAnnotation } from './execution-annotation.ee';
/**
* This entity represents the junction table between the execution annotations and the tags
*/
@Entity({ name: 'execution_annotation_tags' })
export class AnnotationTagMapping {
@PrimaryColumn()
annotationId: number;
@ManyToOne('ExecutionAnnotation', 'tagMappings')
@JoinColumn({ name: 'annotationId' })
annotations: ExecutionAnnotation[];
@PrimaryColumn()
tagId: string;
@ManyToOne('AnnotationTagEntity', 'annotationMappings')
@JoinColumn({ name: 'tagId' })
tags: AnnotationTagEntity[];
}
+33
View File
@@ -0,0 +1,33 @@
import type { ApiKeyScope } from '@n8n/permissions';
import { Column, Entity, Index, ManyToOne, Unique } from '@n8n/typeorm';
import { ApiKeyAudience } from 'n8n-workflow';
import { JsonColumn, WithTimestampsAndStringId } from './abstract-entity';
import { User } from './user';
@Entity('user_api_keys')
@Unique(['userId', 'label'])
export class ApiKey extends WithTimestampsAndStringId {
@ManyToOne(
() => User,
(user) => user.id,
{ onDelete: 'CASCADE' },
)
user: User;
@Column({ type: String })
userId: string;
@Column({ type: String })
label: string;
@JsonColumn({ nullable: false })
scopes: ApiKeyScope[];
@Index({ unique: true })
@Column({ type: String })
apiKey: string;
@Column({ type: String, default: 'public-api' })
audience: ApiKeyAudience;
}
@@ -0,0 +1,37 @@
import { Column, Entity, ManyToOne, PrimaryColumn, Unique } from '@n8n/typeorm';
import { WithTimestamps } from './abstract-entity';
import { AuthProviderType } from './types-db';
import { User } from './user';
@Entity()
@Unique(['providerId', 'providerType'])
export class AuthIdentity extends WithTimestamps {
@Column()
userId: string;
@ManyToOne(
() => User,
(user) => user.authIdentities,
)
user: User;
@PrimaryColumn({ length: 255 })
providerId: string;
@PrimaryColumn()
providerType: AuthProviderType;
static create(
user: User,
providerId: string,
providerType: AuthProviderType = 'ldap',
): AuthIdentity {
const identity = new AuthIdentity();
identity.user = user;
identity.userId = user.id;
identity.providerId = providerId;
identity.providerType = providerType;
return identity;
}
}
@@ -0,0 +1,40 @@
import { Column, Entity, PrimaryGeneratedColumn } from '@n8n/typeorm';
import { DateTimeColumn } from './abstract-entity';
import { AuthProviderType, RunningMode, SyncStatus } from './types-db';
@Entity()
export class AuthProviderSyncHistory {
@PrimaryGeneratedColumn()
id: number;
@Column('text')
providerType: AuthProviderType;
@Column('text')
runMode: RunningMode;
@Column('text')
status: SyncStatus;
@DateTimeColumn()
startedAt: Date;
@DateTimeColumn()
endedAt: Date;
@Column()
scanned: number;
@Column()
created: number;
@Column()
updated: number;
@Column()
disabled: number;
@Column()
error: string;
}
@@ -0,0 +1,34 @@
import { Column, Entity, Index, PrimaryColumn } from '@n8n/typeorm';
import { z } from 'zod';
import { BinaryColumn, WithTimestamps } from './abstract-entity';
export const SourceTypeSchema = z.enum(['execution', 'chat_message_attachment']);
export type SourceType = z.infer<typeof SourceTypeSchema>;
@Entity('binary_data')
export class BinaryDataFile extends WithTimestamps {
@PrimaryColumn('uuid')
fileId: string;
@Column('varchar', { length: 50 })
sourceType: SourceType;
@Column('varchar', { length: 255 })
sourceId: string;
@BinaryColumn()
data: Buffer;
@Column('varchar', { length: 255, nullable: true })
mimeType: string | null;
@Column('varchar', { length: 255, nullable: true })
fileName: string | null;
@Column('int')
fileSize: number; // bytes
}
Index(['sourceType', 'sourceId'])(BinaryDataFile);
@@ -0,0 +1,68 @@
import { Column, Entity, Index, OneToMany } from '@n8n/typeorm';
import { IsObject, IsString, Length } from 'class-validator';
import { WithTimestampsAndStringId } from './abstract-entity';
import type { SharedCredentials } from './shared-credentials';
import type { ICredentialsDb } from './types-db';
@Entity()
export class CredentialsEntity extends WithTimestampsAndStringId implements ICredentialsDb {
@Column({ length: 128 })
@IsString({ message: 'Credential `name` must be of type string.' })
@Length(3, 128, {
message: 'Credential name must be $constraint1 to $constraint2 characters long.',
})
name: string;
@Column('text')
@IsObject()
data: string;
@Index()
@IsString({ message: 'Credential `type` must be of type string.' })
@Column({
length: 128,
})
type: string;
@OneToMany('SharedCredentials', 'credentials')
shared: SharedCredentials[];
/**
* Whether the credential is managed by n8n. We currently use this flag
* to provide OpenAI free credits on cloud. Managed credentials cannot be
* edited by the user.
*/
@Column({ default: false })
isManaged: boolean;
/**
* Whether the credential is available for use by all users.
*/
@Column({ default: false })
isGlobal: boolean;
/**
* Whether the credential can be dynamically resolved by a resolver.
*/
@Column({ default: false })
isResolvable: boolean;
/**
* Whether the credential resolver should allow falling back to static credentials
* if dynamic resolution fails.
*/
@Column({ default: false })
resolvableAllowFallback: boolean;
/**
* ID of the dynamic credential resolver associated with this credential.
*/
@Column({ type: 'varchar', nullable: true })
resolverId: string | null;
toJSON() {
const { shared, ...rest } = this;
return rest;
}
}
@@ -0,0 +1,62 @@
import {
Column,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
RelationId,
} from '@n8n/typeorm';
import type { AnnotationVote } from 'n8n-workflow';
import type { AnnotationTagEntity } from './annotation-tag-entity.ee';
import type { AnnotationTagMapping } from './annotation-tag-mapping.ee';
import { ExecutionEntity } from './execution-entity';
@Entity({ name: 'execution_annotations' })
export class ExecutionAnnotation {
@PrimaryGeneratedColumn()
id: number;
/**
* This field stores the up- or down-vote of the execution by user.
*/
@Column({ type: 'varchar', nullable: true })
vote: AnnotationVote | null;
/**
* Custom text note added to the execution by user.
*/
@Column({ type: 'varchar', nullable: true })
note: string | null;
@RelationId((annotation: ExecutionAnnotation) => annotation.execution)
executionId: string;
@Index({ unique: true })
@OneToOne('ExecutionEntity', 'annotation', {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'executionId' })
execution: ExecutionEntity;
@ManyToMany('AnnotationTagEntity', 'annotations')
@JoinTable({
name: 'execution_annotation_tags', // table name for the junction table of this relation
joinColumn: {
name: 'annotationId',
referencedColumnName: 'id',
},
inverseJoinColumn: {
name: 'tagId',
referencedColumnName: 'id',
},
})
tags?: AnnotationTagEntity[];
@OneToMany('AnnotationTagMapping', 'annotations')
tagMappings: AnnotationTagMapping[];
}
@@ -0,0 +1,40 @@
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from '@n8n/typeorm';
import { IWorkflowBase } from 'n8n-workflow';
import { JsonColumn } from './abstract-entity';
import { ExecutionEntity } from './execution-entity';
import { ISimplifiedPinData } from './types-db';
import { idStringifier } from '../utils/transformers';
@Entity()
export class ExecutionData {
@Column('text')
data: string;
// WARNING: the workflowData column has been changed from IWorkflowDb to IWorkflowBase
// when ExecutionData was introduced as a separate entity.
// This is because manual executions of unsaved workflows have no workflow id
// and IWorkflowDb has it as a mandatory field. IWorkflowBase reflects the correct
// data structure for this entity.
/**
* Workaround: Pindata causes TS errors from excessively deep type instantiation
* due to `INodeExecutionData`, so we use a simplified version so `QueryDeepPartialEntity`
* can resolve and calls to `update`, `insert`, and `insert` pass typechecking.
*/
@JsonColumn()
workflowData: Omit<IWorkflowBase, 'pinData'> & { pinData?: ISimplifiedPinData };
@PrimaryColumn({ transformer: idStringifier })
executionId: string;
@Column({ type: 'varchar', length: 36, nullable: true })
workflowVersionId: string | null;
@OneToOne('ExecutionEntity', 'executionData', {
onDelete: 'CASCADE',
})
@JoinColumn({
name: 'executionId',
})
execution: ExecutionEntity;
}
@@ -0,0 +1,99 @@
import {
Column,
Entity,
Generated,
Index,
ManyToOne,
OneToMany,
OneToOne,
PrimaryColumn,
Relation,
DeleteDateColumn,
} from '@n8n/typeorm';
import type { SimpleColumnType } from '@n8n/typeorm/driver/types/ColumnTypes';
import { ExecutionStatus, WorkflowExecuteMode } from 'n8n-workflow';
import { DateTimeColumn, datetimeColumnType } from './abstract-entity';
import type { ExecutionAnnotation } from './execution-annotation.ee';
import type { ExecutionData } from './execution-data';
import type { ExecutionMetadata } from './execution-metadata';
import { WorkflowEntity } from './workflow-entity';
import { idStringifier } from '../utils/transformers';
export type ExecutionDataStorageLocation = 'db' | 'fs';
@Entity()
@Index(['workflowId', 'id'])
@Index(['waitTill', 'id'])
@Index(['finished', 'id'])
@Index(['workflowId', 'finished', 'id'])
@Index(['workflowId', 'waitTill', 'id'])
export class ExecutionEntity {
@Generated()
@PrimaryColumn({ transformer: idStringifier })
id: string;
/**
* Whether the execution finished successfully.
*
* @deprecated Use `status` instead
*/
@Column()
finished: boolean;
@Column('varchar')
mode: WorkflowExecuteMode;
@Column({ nullable: true })
retryOf: string;
@Column({ nullable: true })
retrySuccessId: string;
@Column('varchar')
status: ExecutionStatus;
@Column(datetimeColumnType)
createdAt: Date;
/**
* Time when the processing of the execution actually started. This column
* is `null` when an execution is enqueued but has not started yet.
*/
@Column({
type: datetimeColumnType as SimpleColumnType,
nullable: true,
})
startedAt: Date | null;
@Index()
@DateTimeColumn({ nullable: true })
stoppedAt: Date;
@DeleteDateColumn({ type: datetimeColumnType as SimpleColumnType, nullable: true })
deletedAt: Date;
@Column({ nullable: true })
workflowId: string;
@DateTimeColumn({ nullable: true })
waitTill: Date | null;
/**
* Where the execution data is stored at: 'db' (database), 'fs' (filesystem), or 's3'.
*/
@Column({ type: 'varchar', length: 2, nullable: false, default: 'db' })
storedAt: ExecutionDataStorageLocation;
@OneToMany('ExecutionMetadata', 'execution')
metadata: ExecutionMetadata[];
@OneToOne('ExecutionData', 'execution')
executionData: Relation<ExecutionData>;
@OneToOne('ExecutionAnnotation', 'execution')
annotation?: Relation<ExecutionAnnotation>;
@ManyToOne('WorkflowEntity')
workflow: WorkflowEntity;
}
@@ -0,0 +1,23 @@
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from '@n8n/typeorm';
import { ExecutionEntity } from './execution-entity';
@Entity()
export class ExecutionMetadata {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne('ExecutionEntity', 'metadata', {
onDelete: 'CASCADE',
})
execution: ExecutionEntity;
@Column()
executionId: string;
@Column('text')
key: string;
@Column('text')
value: string;
}
@@ -0,0 +1,21 @@
import { Entity, JoinColumn, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import type { Folder } from './folder';
import type { TagEntity } from './tag-entity';
@Entity({ name: 'folder_tag' })
export class FolderTagMapping {
@PrimaryColumn()
folderId: string;
@ManyToOne('Folder', 'tagMappings')
@JoinColumn({ name: 'folderId' })
folders: Folder[];
@PrimaryColumn()
tagId: string;
@ManyToOne('TagEntity', 'folderMappings')
@JoinColumn({ name: 'tagId' })
tags: TagEntity[];
}
+54
View File
@@ -0,0 +1,54 @@
import {
Column,
Entity,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
OneToMany,
} from '@n8n/typeorm';
import { WithTimestampsAndStringId } from './abstract-entity';
import { Project } from './project';
import { TagEntity } from './tag-entity';
import type { WorkflowEntity } from './workflow-entity';
@Entity()
export class Folder extends WithTimestampsAndStringId {
@Column()
name: string;
@Column({ nullable: true })
parentFolderId: string | null;
@ManyToOne(() => Folder, { nullable: true, onDelete: 'CASCADE' })
@JoinColumn({ name: 'parentFolderId' })
parentFolder: Folder | null;
@OneToMany(
() => Folder,
(folder) => folder.parentFolder,
)
subFolders: Folder[];
@ManyToOne(() => Project)
@JoinColumn({ name: 'projectId' })
homeProject: Project;
@OneToMany('WorkflowEntity', 'parentFolder')
workflows: WorkflowEntity[];
@ManyToMany(() => TagEntity)
@JoinTable({
name: 'folder_tag',
joinColumn: {
name: 'folderId',
referencedColumnName: 'id',
},
inverseJoinColumn: {
name: 'tagId',
referencedColumnName: 'id',
},
})
tags: TagEntity[];
}
+118
View File
@@ -0,0 +1,118 @@
import { AnnotationTagEntity } from './annotation-tag-entity.ee';
import { AnnotationTagMapping } from './annotation-tag-mapping.ee';
import { ApiKey } from './api-key';
import { AuthIdentity } from './auth-identity';
import { AuthProviderSyncHistory } from './auth-provider-sync-history';
import { BinaryDataFile, SourceTypeSchema, type SourceType } from './binary-data-file';
import { CredentialsEntity } from './credentials-entity';
import { ExecutionAnnotation } from './execution-annotation.ee';
import { ExecutionData } from './execution-data';
import { ExecutionEntity } from './execution-entity';
import type { ExecutionDataStorageLocation } from './execution-entity';
import { ExecutionMetadata } from './execution-metadata';
import { Folder } from './folder';
import { FolderTagMapping } from './folder-tag-mapping';
import { InvalidAuthToken } from './invalid-auth-token';
import { ProcessedData } from './processed-data';
import { Project } from './project';
import { ProjectRelation } from './project-relation';
import { ProjectSecretsProviderAccess } from './project-secrets-provider-access';
import { Role } from './role';
import { Scope } from './scope';
import { SecretsProviderConnection } from './secrets-provider-connection';
import { Settings } from './settings';
import { SharedCredentials } from './shared-credentials';
import { SharedWorkflow } from './shared-workflow';
import { TagEntity } from './tag-entity';
import { TestCaseExecution } from './test-case-execution.ee';
import { TestRun } from './test-run.ee';
import { User } from './user';
import { Variables } from './variables';
import { WebhookEntity } from './webhook-entity';
import { WorkflowDependency } from './workflow-dependency-entity';
import { WorkflowEntity } from './workflow-entity';
import { WorkflowHistory } from './workflow-history';
import { WorkflowPublishHistory } from './workflow-publish-history';
import { WorkflowStatistics } from './workflow-statistics';
import { WorkflowTagMapping } from './workflow-tag-mapping';
export {
InvalidAuthToken,
ProcessedData,
Settings,
Variables,
ApiKey,
BinaryDataFile,
SourceTypeSchema,
type SourceType,
type ExecutionDataStorageLocation,
WebhookEntity,
AuthIdentity,
CredentialsEntity,
Folder,
Project,
ProjectRelation,
Role,
Scope,
SharedCredentials,
SharedWorkflow,
TagEntity,
User,
WorkflowDependency,
WorkflowEntity,
WorkflowStatistics,
WorkflowTagMapping,
FolderTagMapping,
AuthProviderSyncHistory,
WorkflowHistory,
WorkflowPublishHistory,
ExecutionData,
ExecutionMetadata,
AnnotationTagEntity,
ExecutionAnnotation,
AnnotationTagMapping,
TestRun,
TestCaseExecution,
ExecutionEntity,
ProjectSecretsProviderAccess,
SecretsProviderConnection,
};
export const entities = {
InvalidAuthToken,
ProcessedData,
Settings,
Variables,
ApiKey,
BinaryDataFile,
WebhookEntity,
AuthIdentity,
CredentialsEntity,
Folder,
Project,
ProjectRelation,
Scope,
SharedCredentials,
SharedWorkflow,
TagEntity,
User,
WorkflowDependency,
WorkflowEntity,
WorkflowStatistics,
WorkflowTagMapping,
FolderTagMapping,
AuthProviderSyncHistory,
WorkflowHistory,
WorkflowPublishHistory,
ExecutionData,
ExecutionMetadata,
AnnotationTagEntity,
ExecutionAnnotation,
AnnotationTagMapping,
TestRun,
TestCaseExecution,
ExecutionEntity,
Role,
ProjectSecretsProviderAccess,
SecretsProviderConnection,
};
@@ -0,0 +1,12 @@
import { Entity, PrimaryColumn } from '@n8n/typeorm';
import { DateTimeColumn } from './abstract-entity';
@Entity()
export class InvalidAuthToken {
@PrimaryColumn()
token: string;
@DateTimeColumn()
expiresAt: Date;
}
@@ -0,0 +1,20 @@
import { Entity, PrimaryColumn } from '@n8n/typeorm';
import type { IProcessedDataEntries, IProcessedDataLatest } from 'n8n-workflow';
import { JsonColumn, WithTimestamps } from './abstract-entity';
import { objectRetriever } from '../utils/transformers';
@Entity()
export class ProcessedData extends WithTimestamps {
@PrimaryColumn('varchar')
context: string;
@PrimaryColumn()
workflowId: string;
@JsonColumn({
nullable: true,
transformer: objectRetriever,
})
value: IProcessedDataEntries | IProcessedDataLatest;
}
@@ -0,0 +1,25 @@
import { Entity, JoinColumn, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import { WithTimestamps } from './abstract-entity';
import { Project } from './project';
import { Role } from './role';
import { User } from './user';
@Entity()
export class ProjectRelation extends WithTimestamps {
@ManyToOne('Role', 'projectRelations')
@JoinColumn({ name: 'role', referencedColumnName: 'slug' })
role: Role;
@ManyToOne('User', 'projectRelations')
user: User;
@PrimaryColumn('uuid')
userId: string;
@ManyToOne('Project', 'projectRelations')
project: Project;
@PrimaryColumn()
projectId: string;
}
@@ -0,0 +1,20 @@
import { Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import { WithTimestamps } from './abstract-entity';
import { Project } from './project';
import { SecretsProviderConnection } from './secrets-provider-connection';
@Entity()
export class ProjectSecretsProviderAccess extends WithTimestamps {
@ManyToOne('SecretsProviderConnection', 'projectAccess')
secretsProviderConnection: SecretsProviderConnection;
@PrimaryColumn()
secretsProviderConnectionId: number;
@ManyToOne('Project', 'secretsProviderAccess', { eager: true })
project: Project;
@PrimaryColumn()
projectId: string;
}
+46
View File
@@ -0,0 +1,46 @@
import { Column, Entity, JoinColumn, ManyToOne, OneToMany, Relation } from '@n8n/typeorm';
import { WithTimestampsAndStringId } from './abstract-entity';
import type { ProjectRelation } from './project-relation';
import type { ProjectSecretsProviderAccess } from './project-secrets-provider-access';
import type { SharedCredentials } from './shared-credentials';
import type { SharedWorkflow } from './shared-workflow';
import { User } from './user';
import type { Variables } from './variables';
@Entity()
export class Project extends WithTimestampsAndStringId {
@Column({ length: 255 })
name: string;
@Column({ type: 'varchar', length: 36 })
type: 'personal' | 'team';
@Column({ type: 'json', nullable: true })
icon: { type: 'emoji' | 'icon'; value: string } | null;
@Column({ type: 'varchar', length: 512, nullable: true })
description: string | null;
@OneToMany('ProjectRelation', 'project')
projectRelations: ProjectRelation[];
@OneToMany('SharedCredentials', 'project')
sharedCredentials: SharedCredentials[];
@OneToMany('SharedWorkflow', 'project')
sharedWorkflows: SharedWorkflow[];
@OneToMany('ProjectSecretsProviderAccess', 'project')
secretsProviderAccess: ProjectSecretsProviderAccess[];
@OneToMany('Variables', 'project')
variables: Variables[];
@Column({ type: String, nullable: true })
creatorId: string | null;
@ManyToOne('User', { onDelete: 'SET NULL' })
@JoinColumn({ name: 'creatorId' })
creator?: Relation<User>;
}
+62
View File
@@ -0,0 +1,62 @@
import { Column, Entity, JoinTable, ManyToMany, OneToMany, PrimaryColumn } from '@n8n/typeorm';
import { WithTimestamps } from './abstract-entity';
import type { ProjectRelation } from './project-relation';
import { Scope } from './scope';
@Entity({
name: 'role',
})
export class Role extends WithTimestamps {
@PrimaryColumn({
type: String,
name: 'slug',
})
slug: string;
@Column({
type: String,
nullable: false,
name: 'displayName',
})
displayName: string;
@Column({
type: String,
nullable: true,
name: 'description',
})
description: string | null;
@Column({
type: Boolean,
default: false,
name: 'systemRole',
})
/**
* Indicates if the role is managed by the system and cannot be edited.
*/
systemRole: boolean;
@Column({
type: String,
name: 'roleType',
})
/**
* Type of the role, e.g., global, project, or workflow.
*/
roleType: 'global' | 'project' | 'workflow' | 'credential';
@OneToMany('ProjectRelation', 'role')
projectRelations: ProjectRelation[];
@ManyToMany(() => Scope, {
eager: true,
})
@JoinTable({
name: 'role_scope',
joinColumn: { name: 'roleSlug', referencedColumnName: 'slug' },
inverseJoinColumn: { name: 'scopeSlug', referencedColumnName: 'slug' },
})
scopes: Scope[];
}
+27
View File
@@ -0,0 +1,27 @@
import type { Scope as ScopeType } from '@n8n/permissions';
import { Column, Entity, PrimaryColumn } from '@n8n/typeorm';
@Entity({
name: 'scope',
})
export class Scope {
@PrimaryColumn({
type: String,
name: 'slug',
})
slug: ScopeType;
@Column({
type: String,
nullable: true,
name: 'displayName',
})
displayName: string | null;
@Column({
type: String,
nullable: true,
name: 'description',
})
description: string | null;
}
@@ -0,0 +1,56 @@
import { Column, Entity, Index, OneToMany, PrimaryGeneratedColumn } from '@n8n/typeorm';
import { WithTimestamps } from './abstract-entity';
import type { ProjectSecretsProviderAccess } from './project-secrets-provider-access';
@Entity()
export class SecretsProviderConnection extends WithTimestamps {
@PrimaryGeneratedColumn()
id: number;
/**
* Unique provider identifier of the secrets provider connection.
* This is the identifier used in the credential expressions e.g. {{ $secrets.<provider-key>.<secret-key>}
*/
@Index({ unique: true })
@Column()
providerKey: string;
/**
* Specifies the provider type, which determines the required settings for connecting to the external secrets provider.
* e.g:
* 'awsSecretsManager',
* 'gcpSecretsManager',
* 'hashicorpVault',
* 'azureKeyVault',
*/
@Column()
type: string;
/**
* Projects that have access to this secrets provider connection.
* If empty, the provider is global and accessible to all projects.
* If populated, the provider is project-scoped and only accessible to the specified projects.
*/
@OneToMany('ProjectSecretsProviderAccess', 'secretsProviderConnection', { eager: true })
projectAccess: ProjectSecretsProviderAccess[];
/**
* Encrypted JSON string containing the connection settings for the secrets provider.
*/
@Column()
encryptedSettings: string;
/**
* @deprecated This field is no longer used.
* Whether the secrets provider connection is enabled.
* When enabled, a connection attempt will be made to the external secrets provider.
* If the connection is successful, secrets will be available to be used in credentials.
*
* When disabled, the secrets provider connection will not be used to connect to the external secrets provider.
*
* This describes an intent rather than a state.
*/
@Column({ default: false })
isEnabled: boolean;
}
+20
View File
@@ -0,0 +1,20 @@
import { Column, Entity, PrimaryColumn } from '@n8n/typeorm';
import type { IDataObject } from 'n8n-workflow';
interface ISettingsDb {
key: string;
value: string | boolean | IDataObject | number;
loadOnStartup: boolean;
}
@Entity()
export class Settings implements ISettingsDb {
@PrimaryColumn()
key: string;
@Column()
value: string;
@Column()
loadOnStartup: boolean;
}
@@ -0,0 +1,24 @@
import { CredentialSharingRole } from '@n8n/permissions';
import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import { WithTimestamps } from './abstract-entity';
import { CredentialsEntity } from './credentials-entity';
import { Project } from './project';
@Entity()
export class SharedCredentials extends WithTimestamps {
@Column({ type: 'varchar' })
role: CredentialSharingRole;
@ManyToOne('CredentialsEntity', 'shared')
credentials: CredentialsEntity;
@PrimaryColumn()
credentialsId: string;
@ManyToOne('Project', 'sharedCredentials')
project: Project;
@PrimaryColumn()
projectId: string;
}
@@ -0,0 +1,24 @@
import { WorkflowSharingRole } from '@n8n/permissions';
import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import { WithTimestamps } from './abstract-entity';
import { Project } from './project';
import { WorkflowEntity } from './workflow-entity';
@Entity()
export class SharedWorkflow extends WithTimestamps {
@Column({ type: 'varchar' })
role: WorkflowSharingRole;
@ManyToOne('WorkflowEntity', 'shared')
workflow: WorkflowEntity;
@PrimaryColumn()
workflowId: string;
@ManyToOne('Project', 'sharedWorkflows')
project: Project;
@PrimaryColumn()
projectId: string;
}
@@ -0,0 +1,25 @@
import { Column, Entity, Index, ManyToMany, OneToMany } from '@n8n/typeorm';
import { IsString, Length } from 'class-validator';
import { WithTimestampsAndStringId } from './abstract-entity';
import type { FolderTagMapping } from './folder-tag-mapping';
import type { WorkflowEntity } from './workflow-entity';
import type { WorkflowTagMapping } from './workflow-tag-mapping';
@Entity()
export class TagEntity extends WithTimestampsAndStringId {
@Column({ length: 24 })
@Index({ unique: true })
@IsString({ message: 'Tag name must be of type string.' })
@Length(1, 24, { message: 'Tag name must be $constraint1 to $constraint2 characters long.' })
name: string;
@ManyToMany('WorkflowEntity', 'tags')
workflows: WorkflowEntity[];
@OneToMany('WorkflowTagMapping', 'tags')
workflowMappings: WorkflowTagMapping[];
@OneToMany('FolderTagMapping', 'tags')
folderMappings: FolderTagMapping[];
}
@@ -0,0 +1,63 @@
import { Column, Entity, ManyToOne, OneToOne } from '@n8n/typeorm';
import type { IDataObject, JsonObject } from 'n8n-workflow';
import { WithStringId, DateTimeColumn, JsonColumn } from './abstract-entity';
import type { ExecutionEntity } from './execution-entity';
import { TestRun } from './test-run.ee';
import type { TestCaseExecutionErrorCode } from './types-db';
export type TestCaseRunMetrics = Record<string, number | boolean>;
export type TestCaseExecutionStatus =
| 'new' // Test case execution was created and added to the test run, but has not been started yet
| 'running' // Workflow under test is running
| 'evaluation_running' // Evaluation workflow is running
| 'success' // Both workflows have completed successfully
| 'error' // An error occurred during the execution of workflow under test or evaluation workflow
| 'warning' // There were warnings during the execution of workflow under test or evaluation workflow. Used only to signal possible issues to user, not to indicate a failure.
| 'cancelled';
/**
* This entity represents the linking between the test runs and individual executions.
* It stores status, link to the evaluation execution, and metrics produced by individual test case
* Entries in this table are meant to outlive the execution entities, which might be pruned over time.
* This allows us to keep track of the details of test runs' status and metrics even after the executions are deleted.
*/
@Entity({ name: 'test_case_execution' })
export class TestCaseExecution extends WithStringId {
@ManyToOne('TestRun')
testRun: TestRun;
@OneToOne('ExecutionEntity', {
onDelete: 'SET NULL',
nullable: true,
})
execution: ExecutionEntity | null;
@Column({ type: 'varchar', nullable: true })
executionId: string | null;
@Column()
status: TestCaseExecutionStatus;
@DateTimeColumn({ nullable: true })
runAt: Date | null;
@DateTimeColumn({ nullable: true })
completedAt: Date | null;
@Column('varchar', { nullable: true })
errorCode: TestCaseExecutionErrorCode | null;
@JsonColumn({ nullable: true })
errorDetails: IDataObject | null;
@JsonColumn({ nullable: true })
metrics: TestCaseRunMetrics;
@JsonColumn({ nullable: true })
inputs: JsonObject | null;
@JsonColumn({ nullable: true })
outputs: JsonObject | null;
}
@@ -0,0 +1,71 @@
import { Column, Entity, OneToMany, ManyToOne } from '@n8n/typeorm';
import type { IDataObject } from 'n8n-workflow';
import { DateTimeColumn, JsonColumn, WithTimestampsAndStringId } from './abstract-entity';
import type { TestCaseExecution } from './test-case-execution.ee';
import { AggregatedTestRunMetrics } from './types-db';
import type { TestRunErrorCode, TestRunFinalResult } from './types-db';
import { WorkflowEntity } from './workflow-entity';
export type TestRunStatus = 'new' | 'running' | 'completed' | 'error' | 'cancelled';
/**
* Entity representing a Test Run.
* It stores info about a specific run of a test, including the status and collected metrics
*/
@Entity()
export class TestRun extends WithTimestampsAndStringId {
@Column('varchar')
status: TestRunStatus;
@DateTimeColumn({ nullable: true })
runAt: Date | null;
@DateTimeColumn({ nullable: true })
completedAt: Date | null;
@JsonColumn({ nullable: true })
metrics: AggregatedTestRunMetrics;
/**
* This will contain the error code if the test run failed.
* This is used for test run level errors, not for individual test case errors.
*/
@Column('varchar', { nullable: true, length: 255 })
errorCode: TestRunErrorCode | null;
/**
* Optional details about the error that happened during the test run
*/
@JsonColumn({ nullable: true })
errorDetails: IDataObject | null;
@OneToMany('TestCaseExecution', 'testRun')
testCaseExecutions: TestCaseExecution[];
@ManyToOne('WorkflowEntity')
workflow: WorkflowEntity;
@Column('varchar', { length: 255 })
workflowId: string;
/**
* ID of the instance that is running this test run.
* Used for coordinating cancellation across multiple main instances.
*/
@Column('varchar', { length: 255, nullable: true })
runningInstanceId: string | null;
/**
* Flag to request cancellation of the test run.
* Used as a fallback mechanism when the running instance cannot be reached via pub/sub.
*/
@Column('boolean', { default: false })
cancelRequested: boolean;
/**
* Calculated property to determine the final result of the test run
* depending on the statuses of test case executions
*/
finalResult?: TestRunFinalResult | null;
}
+427
View File
@@ -0,0 +1,427 @@
import type { Scope } from '@n8n/permissions';
import type { FindOperator } from '@n8n/typeorm';
import type express from 'express';
import type {
ICredentialsEncrypted,
IRunExecutionData,
IWorkflowBase,
WorkflowExecuteMode,
ExecutionStatus,
FeatureFlags,
IUserSettings,
AnnotationVote,
ExecutionSummary,
IUser,
IDataObject,
IBinaryKeyData,
IPairedItemData,
} from 'n8n-workflow';
import { z } from 'zod';
import type { CredentialsEntity } from './credentials-entity';
import type { ExecutionDataStorageLocation } from './execution-entity';
import type { Folder } from './folder';
import type { Project } from './project';
import type { SharedCredentials } from './shared-credentials';
import type { SharedWorkflow } from './shared-workflow';
import type { TagEntity } from './tag-entity';
import type { User } from './user';
import type { WorkflowEntity } from './workflow-entity';
import type { WorkflowHistory } from './workflow-history';
export type UsageCount = {
usageCount: number;
};
export interface ITagBase {
id: string;
name: string;
}
export interface ICredentialsBase {
createdAt: Date;
updatedAt: Date;
}
export interface IExecutionBase {
id: string;
mode: WorkflowExecuteMode;
createdAt: Date; // set by DB
startedAt: Date;
stoppedAt?: Date; // empty value means execution is still running
workflowId: string;
/**
* @deprecated Use `status` instead
*/
finished: boolean;
retryOf?: string; // If it is a retry, the id of the execution it is a retry of.
retrySuccessId?: string; // If it failed and a retry did succeed. The id of the successful retry.
status: ExecutionStatus;
waitTill?: Date | null;
storedAt: ExecutionDataStorageLocation;
}
// Required by PublicUser
export interface IPersonalizationSurveyAnswers {
email: string | null;
codingSkill: string | null;
companyIndustry: string[];
companySize: string | null;
otherCompanyIndustry: string | null;
otherWorkArea: string | null;
workArea: string[] | string | null;
}
export type ITagDb = Pick<TagEntity, 'id' | 'name' | 'createdAt' | 'updatedAt'>;
export type ITagWithCountDb = ITagDb & UsageCount;
// Almost identical to editor-ui.Interfaces.ts
export interface IWorkflowDb extends IWorkflowBase {
triggerCount: number;
tags?: TagEntity[];
parentFolder?: Folder | null;
activeVersion?: WorkflowHistory | null;
}
export interface ICredentialsDb extends ICredentialsBase, ICredentialsEncrypted {
id: string;
name: string;
shared?: SharedCredentials[];
isGlobal?: boolean;
isResolvable?: boolean;
isManaged?: boolean;
}
export interface IExecutionResponse extends IExecutionBase {
id: string;
data: IRunExecutionData;
retryOf?: string;
retrySuccessId?: string;
workflowData: IWorkflowBase | WorkflowWithSharingsAndCredentials;
customData: Record<string, string>;
annotation: {
tags: ITagBase[];
};
}
export interface PublicUser {
id: string;
email?: string;
firstName?: string;
lastName?: string;
personalizationAnswers?: IPersonalizationSurveyAnswers | null;
password?: string;
passwordResetToken?: string;
createdAt: Date;
isPending: boolean;
role?: string;
globalScopes?: Scope[];
signInType: AuthProviderType;
disabled: boolean;
settings?: IUserSettings | null; // External type from n8n-workflow
inviteAcceptUrl?: string;
isOwner?: boolean;
featureFlags?: FeatureFlags; // External type from n8n-workflow
lastActiveAt?: Date | null;
mfaAuthenticated?: boolean;
}
export type UserSettings = Pick<User, 'id' | 'settings'>;
export type SlimProject = Pick<Project, 'id' | 'type' | 'name' | 'icon'>;
export interface CredentialUsedByWorkflow {
id: string;
name: string;
type?: string;
currentUserHasAccess: boolean;
homeProject: SlimProject | null;
sharedWithProjects: SlimProject[];
}
export interface WorkflowWithSharingsAndCredentials extends Omit<WorkflowEntity, 'shared'> {
homeProject?: SlimProject;
sharedWithProjects?: SlimProject[];
usedCredentials?: CredentialUsedByWorkflow[];
shared?: SharedWorkflow[];
}
export interface WorkflowWithSharingsMetaDataAndCredentials extends Omit<WorkflowEntity, 'shared'> {
homeProject?: SlimProject | null;
sharedWithProjects: SlimProject[];
usedCredentials?: CredentialUsedByWorkflow[];
}
/** Payload for creating an execution. */
export type CreateExecutionPayload = Omit<
IExecutionDb,
'id' | 'createdAt' | 'startedAt' | 'storedAt'
>;
// Data in regular format with references
export interface IExecutionDb extends IExecutionBase {
data: IRunExecutionData;
workflowData: IWorkflowBase;
}
export interface IExecutionFlattedDb extends IExecutionBase {
id: string;
data: string;
workflowData: Omit<IWorkflowBase, 'pinData'>;
customData: Record<string, string>;
}
export namespace ExecutionSummaries {
export type Query = RangeQuery | CountQuery;
export type RangeQuery = { kind: 'range' } & FilterFields &
AccessFields &
RangeFields &
OrderFields;
export type CountQuery = { kind: 'count' } & FilterFields & AccessFields;
export type FilterFields = Partial<{
id: string;
finished: boolean;
mode: WorkflowExecuteMode;
retryOf: string;
retrySuccessId: string;
status: ExecutionStatus[];
workflowId: string;
waitTill: boolean;
metadata: Array<{ key: string; value: string; exactMatch?: boolean }>;
startedAfter: string;
startedBefore: string;
annotationTags: string[]; // tag IDs
vote: AnnotationVote;
projectId: string;
}>;
export type StopExecutionFilterQuery = { workflowId: string } & Pick<
FilterFields,
'startedAfter' | 'startedBefore' | 'workflowId' | 'status'
>; // parsed from query params
type AccessFields = {
accessibleWorkflowIds?: string[];
};
type RangeFields = {
range: {
limit: number;
firstId?: string;
lastId?: string;
};
};
type OrderFields = {
order?: {
top?: ExecutionStatus;
startedAt?: 'DESC';
};
};
export type ExecutionSummaryWithScopes = ExecutionSummary & { scopes: Scope[] };
}
export namespace ListQueryDb {
/**
* Slim workflow returned from a list query operation.
*/
export namespace Workflow {
type OptionalBaseFields =
| 'name'
| 'active'
| 'versionId'
| 'activeVersionId'
| 'createdAt'
| 'updatedAt'
| 'tags'
| 'description';
type BaseFields = Pick<WorkflowEntity, 'id'> &
Partial<Pick<WorkflowEntity, OptionalBaseFields>>;
type SharedField = Partial<Pick<WorkflowEntity, 'shared'>>;
type SortingField = 'createdAt' | 'updatedAt' | 'name';
export type SortOrder = `${SortingField}:asc` | `${SortingField}:desc`;
type OwnedByField = { ownedBy: SlimUser | null; homeProject: SlimProject | null };
export type Plain = BaseFields;
export type WithSharing = BaseFields & SharedField;
export type WithOwnership = BaseFields & OwnedByField;
type SharedWithField = { sharedWith: SlimUser[]; sharedWithProjects: SlimProject[] };
export type WithOwnedByAndSharedWith = BaseFields &
OwnedByField &
SharedWithField &
SharedField;
export type WithScopes = BaseFields & ScopesField & SharedField;
}
export namespace Credentials {
type OwnedByField = { homeProject: SlimProject | null };
type SharedField = Partial<Pick<CredentialsEntity, 'shared'>>;
type SharedWithField = { sharedWithProjects: SlimProject[] };
export type WithSharing = CredentialsEntity & SharedField;
export type WithOwnedByAndSharedWith = CredentialsEntity &
OwnedByField &
SharedWithField &
SharedField;
export type WithScopes = CredentialsEntity & ScopesField & SharedField;
}
}
type SlimUser = Pick<IUser, 'id' | 'email' | 'firstName' | 'lastName'>;
export type ScopesField = { scopes: Scope[] };
export const enum StatisticsNames {
productionSuccess = 'production_success',
productionError = 'production_error',
manualSuccess = 'manual_success',
manualError = 'manual_error',
dataLoaded = 'data_loaded',
}
const ALL_AUTH_PROVIDERS = z.enum(['ldap', 'email', 'saml', 'oidc']);
export type AuthProviderType = z.infer<typeof ALL_AUTH_PROVIDERS>;
export function isAuthProviderType(value: string): value is AuthProviderType {
return ALL_AUTH_PROVIDERS.safeParse(value).success;
}
export type FolderWithWorkflowAndSubFolderCount = Folder & {
workflowCount?: boolean;
subFolderCount?: number;
};
export type FolderWithWorkflowAndSubFolderCountAndPath = FolderWithWorkflowAndSubFolderCount & {
path?: string[];
};
export type TestRunFinalResult = 'success' | 'error' | 'warning';
export type TestRunErrorCode =
| 'TEST_CASES_NOT_FOUND'
| 'INTERRUPTED'
| 'UNKNOWN_ERROR'
| 'EVALUATION_TRIGGER_NOT_FOUND'
| 'EVALUATION_TRIGGER_NOT_CONFIGURED'
| 'EVALUATION_TRIGGER_DISABLED'
| 'SET_OUTPUTS_NODE_NOT_CONFIGURED'
| 'SET_METRICS_NODE_NOT_FOUND'
| 'SET_METRICS_NODE_NOT_CONFIGURED'
| 'CANT_FETCH_TEST_CASES';
export type TestCaseExecutionErrorCode =
| 'NO_METRICS_COLLECTED'
| 'MOCKED_NODE_NOT_FOUND' // This will be used when node mocking will be implemented
| 'FAILED_TO_EXECUTE_WORKFLOW'
| 'INVALID_METRICS'
| 'UNKNOWN_ERROR';
export type AggregatedTestRunMetrics = Record<string, number | boolean>;
// Entity representing a node in a workflow under test, for which data should be mocked during test execution
export type MockedNodeItem = {
name?: string;
id: string;
};
export type RunningMode = 'dry' | 'live';
export type SyncStatus = 'success' | 'error';
/** @deprecated This is tech debt. Do not rely on request-level types in repositories. */
export namespace ListQuery {
export type Options = {
filter?: Record<string, unknown>;
select?: Record<string, true>;
skip?: number;
take?: number;
sortBy?: string;
};
}
export interface IGetExecutionsQueryFilter {
id?: FindOperator<string> | string;
finished?: boolean;
mode?: string;
retryOf?: string;
retrySuccessId?: string;
status?: ExecutionStatus[];
workflowId?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
waitTill?: FindOperator<any> | boolean;
metadata?: Array<{ key: string; value: string; exactMatch?: boolean }>;
startedAfter?: string;
startedBefore?: string;
}
export type ResourceType = 'folder' | 'workflow';
export type WorkflowFolderUnionFull = (
| ListQueryDb.Workflow.Plain
| ListQueryDb.Workflow.WithSharing
| FolderWithWorkflowAndSubFolderCount
) & {
resource: ResourceType;
};
export type APIRequest<
RouteParams = {},
ResponseBody = {},
RequestBody = {},
RequestQuery = {},
> = express.Request<RouteParams, ResponseBody, RequestBody, RequestQuery> & {
browserId?: string;
};
export type AuthenticationInformation = {
usedMfa: boolean;
// Indicates the user is logged in but hasn't completed required MFA enrollment
mfaEnrollmentRequired?: boolean;
};
export type AuthenticatedRequest<
RouteParams = {},
ResponseBody = {},
RequestBody = {},
RequestQuery = {},
> = Omit<APIRequest<RouteParams, ResponseBody, RequestBody, RequestQuery>, 'user' | 'cookies'> & {
user: User;
authInfo?: AuthenticationInformation;
cookies: Record<string, string | undefined>;
headers: express.Request['headers'] & {
'push-ref': string;
};
};
/**
* Simplified to prevent excessively deep type instantiation error from
* `INodeExecutionData` in `IPinData` in a TypeORM entity field.
*/
export interface ISimplifiedPinData {
[nodeName: string]: Array<{
json: IDataObject;
binary?: IBinaryKeyData;
pairedItem?: IPairedItemData | IPairedItemData[] | number;
}>;
}
+143
View File
@@ -0,0 +1,143 @@
import type { AuthPrincipal } from '@n8n/permissions';
import {
AfterLoad,
AfterUpdate,
BeforeUpdate,
Column,
Entity,
Index,
OneToMany,
PrimaryGeneratedColumn,
BeforeInsert,
JoinColumn,
ManyToOne,
} from '@n8n/typeorm';
import type { IUser, IUserSettings } from 'n8n-workflow';
import { JsonColumn, WithTimestamps } from './abstract-entity';
import type { ApiKey } from './api-key';
import type { AuthIdentity } from './auth-identity';
import type { ProjectRelation } from './project-relation';
import { Role } from './role';
import type { SharedCredentials } from './shared-credentials';
import type { SharedWorkflow } from './shared-workflow';
import type { IPersonalizationSurveyAnswers } from './types-db';
import { GLOBAL_OWNER_ROLE } from '../constants';
import { isValidEmail } from '../utils/is-valid-email';
import { lowerCaser, objectRetriever } from '../utils/transformers';
@Entity()
export class User extends WithTimestamps implements IUser, AuthPrincipal {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({
length: 254,
nullable: true,
transformer: lowerCaser,
})
@Index({ unique: true })
email: string;
@Column({ length: 32, nullable: true })
firstName: string;
@Column({ length: 32, nullable: true })
lastName: string;
@Column({ type: String, nullable: true })
password: string | null;
@JsonColumn({
nullable: true,
transformer: objectRetriever,
})
personalizationAnswers: IPersonalizationSurveyAnswers | null;
@JsonColumn({ nullable: true })
settings: IUserSettings | null;
@ManyToOne(() => Role)
@JoinColumn({ name: 'roleSlug', referencedColumnName: 'slug' })
role: Role;
@OneToMany('AuthIdentity', 'user')
authIdentities: AuthIdentity[];
@OneToMany('ApiKey', 'user')
apiKeys: ApiKey[];
@OneToMany('SharedWorkflow', 'user')
sharedWorkflows: SharedWorkflow[];
@OneToMany('SharedCredentials', 'user')
sharedCredentials: SharedCredentials[];
@OneToMany('ProjectRelation', 'user')
projectRelations: ProjectRelation[];
@Column({ type: Boolean, default: false })
disabled: boolean;
@BeforeInsert()
@BeforeUpdate()
preUpsertHook(): void {
this.email = this.email?.toLowerCase() ?? null;
// Validate email if present (including empty strings)
if (this.email !== null && this.email !== undefined) {
const result = isValidEmail(this.email);
if (!result) {
throw new Error(`Cannot save user <${this.email}>: Provided email is invalid`);
}
}
}
@Column({ type: Boolean, default: false })
mfaEnabled: boolean;
@Column({ type: String, nullable: true })
mfaSecret?: string | null;
@Column({ type: 'simple-array', default: '' })
mfaRecoveryCodes: string[];
@Column({ type: 'date', nullable: true })
lastActiveAt?: Date | null;
/**
* Whether the user is pending setup completion.
*/
isPending: boolean;
@AfterLoad()
@AfterUpdate()
computeIsPending(): void {
const hasExternalAuthIdentity =
this.authIdentities?.some((identity) => identity.providerType !== 'email') ?? false;
this.isPending =
this.password === null &&
!hasExternalAuthIdentity &&
this.role?.slug !== GLOBAL_OWNER_ROLE.slug;
}
toJSON() {
const { password, mfaSecret, mfaRecoveryCodes, ...rest } = this;
return rest;
}
createPersonalProjectName() {
if (this.firstName && this.lastName && this.email) {
return `${this.firstName} ${this.lastName} <${this.email}>`;
} else if (this.email) {
return `<${this.email}>`;
} else {
return 'Unnamed Project';
}
}
toIUser(): IUser {
const { id, email, firstName, lastName } = this;
return { id, email, firstName, lastName };
}
}
@@ -0,0 +1,23 @@
import { Column, Entity, ManyToOne } from '@n8n/typeorm';
import { WithStringId } from './abstract-entity';
import type { Project } from './project';
@Entity()
export class Variables extends WithStringId {
@Column('text')
key: string;
@Column('text', { default: 'string' })
type: string;
@Column('text')
value: string;
// If null, it's a global variable
@ManyToOne('Project', {
onDelete: 'CASCADE',
nullable: true,
})
project: Project | null;
}
@@ -0,0 +1,57 @@
import { Column, Entity, Index, PrimaryColumn } from '@n8n/typeorm';
import { IHttpRequestMethods } from 'n8n-workflow';
@Entity()
@Index(['webhookId', 'method', 'pathLength'])
export class WebhookEntity {
@Column()
workflowId: string;
@PrimaryColumn()
webhookPath: string;
@PrimaryColumn({ type: 'text' })
method: IHttpRequestMethods;
@Column()
node: string;
@Column({ nullable: true })
webhookId?: string;
@Column({ nullable: true })
pathLength?: number;
/**
* Unique section of webhook path.
*
* - Static: `${uuid}` or `user/defined/path`
* - Dynamic: `${uuid}/user/:id/posts`
*
* Appended to `${instanceUrl}/webhook/` or `${instanceUrl}/test-webhook/`.
*/
private get uniquePath() {
return this.webhookPath.includes(':')
? [this.webhookId, this.webhookPath].join('/')
: this.webhookPath;
}
get cacheKey() {
return `webhook:${this.method}-${this.uniquePath}`;
}
get staticSegments() {
return this.webhookPath.split('/').filter((s) => !s.startsWith(':'));
}
/**
* Whether the webhook has at least one dynamic path segment, e.g. `:id` in `<uuid>/user/:id/posts`.
*/
get isDynamic() {
return this.webhookPath.split('/').some((s) => s.startsWith(':'));
}
display() {
return `${this.method} ${this.webhookPath}`;
}
}
@@ -0,0 +1,83 @@
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Relation,
} from '@n8n/typeorm';
import { WithCreatedAt } from './abstract-entity';
import type { WorkflowEntity } from './workflow-entity';
export type DependencyType =
| 'credentialId'
| 'nodeType'
| 'webhookPath'
| 'workflowCall'
| 'workflowIndexed';
@Entity({ name: 'workflow_dependency' })
export class WorkflowDependency extends WithCreatedAt {
@PrimaryGeneratedColumn()
id: number;
/**
* The ID of the workflow the dependency belongs to.
*/
@Column({ length: 36 })
@Index()
workflowId: string;
/**
* The version ID of the workflow the dependency belongs to.
* Used to ensure consistency between the workflow and dependency tables.
*/
@Column({ type: 'int' })
workflowVersionId: number;
/**
* The published version ID, if this dependency belongs to a published workflow version.
* - NULL = draft dependency (current behavior)
* - UUID value = published version dependency (the activeVersionId)
*/
@Column({ type: 'varchar', length: 36, nullable: true })
@Index()
publishedVersionId: string | null;
/**
* The type of the dependency.
* credentialId | nodeType | webhookPath | workflowCall | workflowIndexed
*/
@Column({ length: 32 })
@Index()
dependencyType: DependencyType;
/**
* The ID of the dependency, interpreted based on the dependency type.
* E.g., for 'credentialId' it would be the credential ID, for 'nodeType' the node type name, etc.
*/
@Column({ length: 255 })
@Index()
dependencyKey: string;
/**
* Additional information about the dependency, interpreted based on the type.
* E.g., for 'nodeType' it could be the node ID, for 'webhookPath' the webhook ID.
*/
@Column({ type: 'json', nullable: true })
dependencyInfo: Record<string, unknown> | null;
/**
* The version of the index structure. Used for migrations and updates.
*/
@Column({ type: 'smallint', default: 1 })
indexVersionId: number;
@ManyToOne('WorkflowEntity', {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'workflowId' })
workflow: Relation<WorkflowEntity>;
}
@@ -0,0 +1,127 @@
import {
Column,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
OneToMany,
} from '@n8n/typeorm';
import { Length } from 'class-validator';
import { IConnections, IDataObject, IWorkflowSettings, WorkflowFEMeta } from 'n8n-workflow';
import type { INode } from 'n8n-workflow';
import { JsonColumn, WithTimestampsAndStringId, dbType } from './abstract-entity';
import { type Folder } from './folder';
import type { SharedWorkflow } from './shared-workflow';
import type { TagEntity } from './tag-entity';
import type { TestRun } from './test-run.ee';
import type { ISimplifiedPinData, IWorkflowDb } from './types-db';
import type { WorkflowHistory } from './workflow-history';
import type { WorkflowTagMapping } from './workflow-tag-mapping';
import { objectRetriever, sqlite } from '../utils/transformers';
@Entity()
export class WorkflowEntity extends WithTimestampsAndStringId implements IWorkflowDb {
// TODO: Add XSS check
@Index({ unique: true })
@Length(1, 128, {
message: 'Workflow name must be $constraint1 to $constraint2 characters long.',
})
@Column({ length: 128 })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
/** @deprecated Please rely on `activeVersionId` being not `null` instead. */
@Column()
active: boolean;
/**
* Indicates whether the workflow has been soft-deleted (`true`) or not (`false`).
*
* Archived workflows can be restored (unarchived) or deleted permanently,
* and they can still be executed as sub workflow executions, but they
* cannot be activated or modified.
*/
@Column({ default: false })
isArchived: boolean;
@JsonColumn()
nodes: INode[];
@JsonColumn()
connections: IConnections;
@JsonColumn({ nullable: true })
settings?: IWorkflowSettings;
@JsonColumn({
nullable: true,
transformer: objectRetriever,
})
staticData?: IDataObject;
@JsonColumn({
nullable: true,
transformer: objectRetriever,
})
meta?: WorkflowFEMeta;
@ManyToMany('TagEntity', 'workflows')
@JoinTable({
name: 'workflows_tags', // table name for the junction table of this relation
joinColumn: {
name: 'workflowId',
referencedColumnName: 'id',
},
inverseJoinColumn: {
name: 'tagId',
referencedColumnName: 'id',
},
})
tags?: TagEntity[];
@OneToMany('WorkflowTagMapping', 'workflows')
tagMappings: WorkflowTagMapping[];
@OneToMany('SharedWorkflow', 'workflow')
shared: SharedWorkflow[];
@Column({
type: dbType === 'sqlite' ? 'text' : 'json',
nullable: true,
transformer: sqlite.jsonColumn,
})
pinData?: ISimplifiedPinData;
@Column({ length: 36 })
versionId: string;
@Column({ name: 'activeVersionId', length: 36, nullable: true })
activeVersionId: string | null;
@ManyToOne('WorkflowHistory', { nullable: true })
@JoinColumn({ name: 'activeVersionId', referencedColumnName: 'versionId' })
activeVersion: WorkflowHistory | null;
@Column({ default: 1 })
versionCounter: number;
// Excludes error and sub-workflow triggers and disabled triggers
// Used for billing of plans based on trigger count
@Column({ default: 0 })
triggerCount: number;
@ManyToOne('Folder', 'workflows', {
nullable: true,
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'parentFolderId' })
parentFolder: Folder | null;
@OneToMany('TestRun', 'workflow')
testRuns: TestRun[];
}
@@ -0,0 +1,42 @@
import { Column, Entity, ManyToOne, OneToMany, PrimaryColumn, Relation } from '@n8n/typeorm';
import { IConnections } from 'n8n-workflow';
import type { INode } from 'n8n-workflow';
import { JsonColumn, WithTimestamps } from './abstract-entity';
import { WorkflowEntity } from './workflow-entity';
import type { WorkflowPublishHistory } from './workflow-publish-history';
@Entity()
export class WorkflowHistory extends WithTimestamps {
@PrimaryColumn()
versionId: string;
@Column()
workflowId: string;
@JsonColumn()
nodes: INode[];
@JsonColumn()
connections: IConnections;
@Column()
authors: string;
@Column({ type: 'text', nullable: true })
name: string | null;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ default: false })
autosaved: boolean;
@ManyToOne('WorkflowEntity', {
onDelete: 'CASCADE',
})
workflow: WorkflowEntity;
@OneToMany('WorkflowPublishHistory', 'workflowHistory')
workflowPublishHistory: Relation<WorkflowPublishHistory[]>;
}
@@ -0,0 +1,49 @@
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToOne,
PrimaryGeneratedColumn,
Relation,
} from '@n8n/typeorm';
import { WithCreatedAt } from './abstract-entity';
import { User } from './user';
import type { WorkflowHistory } from './workflow-history';
@Entity()
@Index(['workflowId', 'versionId'])
export class WorkflowPublishHistory extends WithCreatedAt {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'varchar' })
workflowId: string;
@Column({ type: 'varchar' })
versionId: string;
// Note that we only track "permanent" deactivations
// We don't explicitly track the deactivations of a previous active version
// which happens when a new active version of an already active workflow is published
@Column()
event: 'activated' | 'deactivated';
@Column({ type: 'uuid', nullable: true })
userId: string | null;
@OneToOne('User', {
onDelete: 'SET NULL',
nullable: true,
})
@JoinColumn({ name: 'userId' })
user: User | null;
@ManyToOne('WorkflowHistory', 'workflowPublishHistory', { nullable: true })
@JoinColumn({
name: 'versionId',
})
workflowHistory: Relation<WorkflowHistory> | null;
}
@@ -0,0 +1,33 @@
import { Column, Entity, PrimaryGeneratedColumn } from '@n8n/typeorm';
import { DateTimeColumn } from './abstract-entity';
import { StatisticsNames } from './types-db';
import { bigintStringToNumber } from '../utils/transformers';
@Entity()
export class WorkflowStatistics {
@PrimaryGeneratedColumn()
id: number;
// we expect values beyond JS number precision limits.
@Column({ type: 'bigint', transformer: bigintStringToNumber })
count: number;
// we expect values beyond JS number precision limits.
@Column({ type: 'bigint', transformer: bigintStringToNumber })
rootCount: number;
@DateTimeColumn()
latestEvent: Date;
@Column({ length: 128 })
name: StatisticsNames;
// workflowId is kept as an orphaned reference when workflows are deleted
// No FK constraint in database - allows keeping statistics for deleted workflows
@Column({ type: 'varchar', length: 36 })
workflowId: string;
@Column({ type: 'varchar', length: 128, nullable: true })
workflowName: string | null;
}
@@ -0,0 +1,21 @@
import { Entity, JoinColumn, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import type { TagEntity } from './tag-entity';
import type { WorkflowEntity } from './workflow-entity';
@Entity({ name: 'workflows_tags' })
export class WorkflowTagMapping {
@PrimaryColumn()
workflowId: string;
@ManyToOne('WorkflowEntity', 'tagMappings')
@JoinColumn({ name: 'workflowId' })
workflows: WorkflowEntity[];
@PrimaryColumn()
tagId: string;
@ManyToOne('TagEntity', 'workflowMappings')
@JoinColumn({ name: 'tagId' })
tags: TagEntity[];
}
+45
View File
@@ -0,0 +1,45 @@
export {
WithStringId,
WithTimestamps,
WithTimestampsAndStringId,
jsonColumnType,
datetimeColumnType,
dbType,
JsonColumn,
DateTimeColumn,
} from './entities/abstract-entity';
export { generateNanoId } from '@n8n/utils';
export { generateHostInstanceId } from './utils/generators';
export { isStringArray } from './utils/is-string-array';
export { isValidEmail } from './utils/is-valid-email';
export { separate } from './utils/separate';
export { sql } from './utils/sql';
export { idStringifier, lowerCaser, objectRetriever, sqlite } from './utils/transformers';
export { withTransaction } from './utils/transaction';
export * from './constants';
export * from './entities';
export * from './entities/types-db';
export { NoXss } from './utils/validators/no-xss.validator';
export { NoUrl } from './utils/validators/no-url.validator';
export * from './repositories';
export * from './subscribers';
export { Column as DslColumn } from './migrations/dsl/column';
export { CreateTable } from './migrations/dsl/table';
export { sqliteMigrations } from './migrations/sqlite';
export { postgresMigrations } from './migrations/postgresdb';
export { wrapMigration } from './migrations/migration-helpers';
export * from './migrations/migration-types';
export { DbConnection } from './connection/db-connection';
export { DbConnectionOptions } from './connection/db-connection-options';
export { AuthRolesService } from './services/auth.roles.service';
export { DbLock, DbLockService } from './services/db-lock.service';
export { In, Like, Not, DataSource } from '@n8n/typeorm';
export type { FindManyOptions, FindOptionsWhere } from '@n8n/typeorm';
export type { EntityManager } from '@n8n/typeorm';
@@ -0,0 +1,66 @@
import { wrapMigration } from '../migration-helpers';
import type { IrreversibleMigration, ReversibleMigration } from '../migration-types';
describe('migrationHelpers.wrapMigration', () => {
test('throws if passed a migration without up method', async () => {
//
// ARRANGE
//
class TestMigration {}
//
// ACT & ASSERT
//
expect(() => wrapMigration(TestMigration as never)).toThrow(
'Migration "TestMigration" is missing the method `up`.',
);
});
test('wraps up method', async () => {
//
// ARRANGE
//
class TestMigration implements IrreversibleMigration {
async up() {}
}
const originalUp = jest.fn();
TestMigration.prototype.up = originalUp;
//
// ACT
//
wrapMigration(TestMigration);
await new TestMigration().up();
//
// ASSERT
//
expect(TestMigration.prototype.up).not.toBe(originalUp);
expect(originalUp).toHaveBeenCalledTimes(1);
});
test('wraps down method', async () => {
//
// ARRANGE
//
class TestMigration implements ReversibleMigration {
async up() {}
async down() {}
}
const originalDown = jest.fn();
TestMigration.prototype.down = originalDown;
//
// ACT
//
wrapMigration(TestMigration);
await new TestMigration().down();
//
// ASSERT
//
expect(TestMigration.prototype.down).not.toBe(originalDown);
expect(originalDown).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,40 @@
import type { WorkflowEntity } from '../../entities';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class UniqueWorkflowNames1620821879465 implements ReversibleMigration {
protected indexSuffix = '943d8f922be094eb507cb9a7f9';
async up({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('workflow_entity');
const workflowNames: Array<Pick<WorkflowEntity, 'name'>> = await runQuery(
`SELECT name FROM ${tableName}`,
);
for (const { name } of workflowNames) {
const duplicates: Array<Pick<WorkflowEntity, 'id' | 'name'>> = await runQuery(
`SELECT id, name FROM ${tableName} WHERE name = :name ORDER BY createdAt ASC`,
{ name },
);
if (duplicates.length > 1) {
await Promise.all(
duplicates.map(async (workflow, index) => {
if (index === 0) return;
return await runQuery(`UPDATE ${tableName} SET name = :name WHERE id = :id`, {
name: `${workflow.name} ${index + 1}`,
id: workflow.id,
});
}),
);
}
}
const indexName = escape.indexName(this.indexSuffix);
await runQuery(`CREATE UNIQUE INDEX ${indexName} ON ${tableName} ("name")`);
}
async down({ escape, runQuery }: MigrationContext) {
const indexName = escape.indexName(this.indexSuffix);
await runQuery(`DROP INDEX ${indexName}`);
}
}
@@ -0,0 +1,253 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import type { IWorkflowBase } from 'n8n-workflow';
import type { CredentialsEntity, WorkflowEntity } from '../../entities';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
type Credential = Pick<CredentialsEntity, 'id' | 'name' | 'type'>;
type ExecutionWithData = { id: string; workflowData: string | IWorkflowBase };
type Workflow = Pick<WorkflowEntity, 'id'> & { nodes: string | WorkflowEntity['nodes'] };
// replacing the credentials in workflows and execution
// `nodeType: name` changes to `nodeType: { id, name }`
export class UpdateWorkflowCredentials1630330987096 implements ReversibleMigration {
async up({ dbType, escape, parseJson, runQuery, runInBatches }: MigrationContext) {
const credentialsTable = escape.tableName('credentials_entity');
const workflowsTable = escape.tableName('workflow_entity');
const executionsTable = escape.tableName('execution_entity');
const dataColumn = escape.columnName('workflowData');
const waitTillColumn = escape.columnName('waitTill');
const credentialsEntities: Credential[] = await runQuery(
`SELECT id, name, type FROM ${credentialsTable}`,
);
const workflowsQuery = `SELECT id, nodes FROM ${workflowsTable}`;
await runInBatches<Workflow>(workflowsQuery, async (workflows) => {
workflows.forEach(async (workflow) => {
let credentialsUpdated = false;
const nodes = parseJson(workflow.nodes);
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
const matchingCredentials = credentialsEntities.find(
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id ?? null, name };
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
await runQuery(`UPDATE ${workflowsTable} SET nodes = :nodes WHERE id = :id`, {
nodes: JSON.stringify(nodes),
id: workflow.id,
});
}
});
});
const finishedValue = dbType === 'postgresdb' ? 'FALSE' : '0';
const waitingExecutionsQuery = `
SELECT id, ${dataColumn}
FROM ${executionsTable}
WHERE ${waitTillColumn} IS NOT NULL AND finished = ${finishedValue}
`;
await runInBatches<ExecutionWithData>(waitingExecutionsQuery, async (waitingExecutions) => {
waitingExecutions.forEach(async (execution) => {
let credentialsUpdated = false;
const workflowData = parseJson(execution.workflowData);
workflowData.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
const matchingCredentials = credentialsEntities.find(
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id ?? null, name };
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
await runQuery(
`UPDATE ${executionsTable}
SET ${escape.columnName('workflowData')} = :data WHERE id = :id`,
{ data: JSON.stringify(workflowData), id: execution.id },
);
}
});
});
const retryableExecutions: ExecutionWithData[] = await runQuery(`
SELECT id, ${dataColumn}
FROM ${executionsTable}
WHERE ${waitTillColumn} IS NULL AND finished = ${finishedValue} AND mode != 'retry'
ORDER BY ${escape.columnName('startedAt')} DESC
LIMIT 200
`);
retryableExecutions.forEach(async (execution) => {
let credentialsUpdated = false;
const workflowData = parseJson(execution.workflowData);
workflowData.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
const matchingCredentials = credentialsEntities.find(
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id ?? null, name };
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
await runQuery(
`UPDATE ${executionsTable}
SET ${escape.columnName('workflowData')} = :data WHERE id = :id`,
{ data: JSON.stringify(workflowData), id: execution.id },
);
}
});
}
async down({ dbType, escape, parseJson, runQuery, runInBatches }: MigrationContext) {
const credentialsTable = escape.tableName('credentials_entity');
const workflowsTable = escape.tableName('workflow_entity');
const executionsTable = escape.tableName('execution_entity');
const dataColumn = escape.columnName('workflowData');
const waitTillColumn = escape.columnName('waitTill');
const credentialsEntities: Credential[] = await runQuery(
`SELECT id, name, type FROM ${credentialsTable}`,
);
const workflowsQuery = `SELECT id, nodes FROM ${workflowsTable}`;
await runInBatches<Workflow>(workflowsQuery, async (workflows) => {
workflows.forEach(async (workflow) => {
let credentialsUpdated = false;
const nodes = parseJson(workflow.nodes);
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
const matchingCredentials = credentialsEntities.find(
// double-equals because creds.id can be string or number
// eslint-disable-next-line eqeqeq
(credentials) => credentials.id == creds.id && credentials.type === type,
);
if (matchingCredentials) {
// @ts-ignore
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
}
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
await runQuery(`UPDATE ${workflowsTable} SET nodes = :nodes WHERE id = :id`, {
nodes: JSON.stringify(nodes),
id: workflow.id,
});
}
});
});
const finishedValue = dbType === 'postgresdb' ? 'FALSE' : '0';
const waitingExecutionsQuery = `
SELECT id, ${dataColumn}
FROM ${executionsTable}
WHERE ${waitTillColumn} IS NOT NULL AND finished = ${finishedValue}
`;
await runInBatches<ExecutionWithData>(waitingExecutionsQuery, async (waitingExecutions) => {
waitingExecutions.forEach(async (execution) => {
let credentialsUpdated = false;
const workflowData = parseJson(execution.workflowData);
workflowData.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
const matchingCredentials = credentialsEntities.find(
// double-equals because creds.id can be string or number
// eslint-disable-next-line eqeqeq
(credentials) => credentials.id == creds.id && credentials.type === type,
);
if (matchingCredentials) {
// @ts-ignore
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
}
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
await runQuery(
`UPDATE ${executionsTable}
SET ${escape.columnName('workflowData')} = :data WHERE id = :id`,
{ data: JSON.stringify(workflowData), id: execution.id },
);
}
});
});
const retryableExecutions: ExecutionWithData[] = await runQuery(`
SELECT id, ${dataColumn}
FROM ${executionsTable}
WHERE ${waitTillColumn} IS NULL AND finished = ${finishedValue} AND mode != 'retry'
ORDER BY ${escape.columnName('startedAt')} DESC
LIMIT 200
`);
retryableExecutions.forEach(async (execution) => {
let credentialsUpdated = false;
const workflowData = parseJson(execution.workflowData);
workflowData.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
const matchingCredentials = credentialsEntities.find(
// double-equals because creds.id can be string or number
// eslint-disable-next-line eqeqeq
(credentials) => credentials.id == creds.id && credentials.type === type,
);
if (matchingCredentials) {
// @ts-ignore
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
}
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
await runQuery(
`UPDATE ${executionsTable}
SET ${escape.columnName('workflowData')} = :data WHERE id = :id`,
{ data: JSON.stringify(workflowData), id: execution.id },
);
}
});
}
}
@@ -0,0 +1,43 @@
import type { INode } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import type { WorkflowEntity } from '../../entities';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
type Workflow = Pick<WorkflowEntity, 'id'> & { nodes: string | INode[] };
export class AddNodeIds1658930531669 implements ReversibleMigration {
async up({ escape, runQuery, runInBatches, parseJson }: MigrationContext) {
const tableName = escape.tableName('workflow_entity');
const workflowsQuery = `SELECT id, nodes FROM ${tableName}`;
await runInBatches<Workflow>(workflowsQuery, async (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = parseJson(workflow.nodes);
nodes.forEach((node: INode) => {
if (!node.id) {
node.id = uuid();
}
});
await runQuery(`UPDATE ${tableName} SET nodes = :nodes WHERE id = :id`, {
nodes: JSON.stringify(nodes),
id: workflow.id,
});
});
});
}
async down({ escape, runQuery, runInBatches, parseJson }: MigrationContext) {
const tableName = escape.tableName('workflow_entity');
const workflowsQuery = `SELECT id, nodes FROM ${tableName}`;
await runInBatches<Workflow>(workflowsQuery, async (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = parseJson(workflow.nodes).map(({ id, ...rest }) => rest);
await runQuery(`UPDATE ${tableName} SET nodes = :nodes WHERE id = :id`, {
nodes: JSON.stringify(nodes),
id: workflow.id,
});
});
});
}
}
@@ -0,0 +1,82 @@
import { isObjectLiteral } from '@n8n/backend-common';
import type { IDataObject, INodeExecutionData } from 'n8n-workflow';
import type { MigrationContext, IrreversibleMigration } from '../migration-types';
type OldPinnedData = { [nodeName: string]: IDataObject[] };
type NewPinnedData = { [nodeName: string]: INodeExecutionData[] };
type Workflow = { id: number; pinData: string | OldPinnedData };
function isJsonKeyObject(item: unknown): item is {
json: unknown;
[keys: string]: unknown;
} {
if (!isObjectLiteral(item)) return false;
return Object.keys(item).includes('json');
}
/**
* Convert TEXT-type `pinData` column in `workflow_entity` table from
* `{ [nodeName: string]: IDataObject[] }` to `{ [nodeName: string]: INodeExecutionData[] }`
*/
export class AddJsonKeyPinData1659888469333 implements IrreversibleMigration {
async up({ escape, runQuery, runInBatches }: MigrationContext) {
const tableName = escape.tableName('workflow_entity');
const columnName = escape.columnName('pinData');
const selectQuery = `SELECT id, ${columnName} FROM ${tableName} WHERE ${columnName} IS NOT NULL`;
await runInBatches<Workflow>(selectQuery, async (workflows) => {
await Promise.all(
this.makeUpdateParams(workflows).map(
async (workflow) =>
await runQuery(`UPDATE ${tableName} SET ${columnName} = :pinData WHERE id = :id;`, {
pinData: workflow.pinData,
id: workflow.id,
}),
),
);
});
}
private makeUpdateParams(fetchedWorkflows: Workflow[]) {
return fetchedWorkflows.reduce<Workflow[]>((updateParams, { id, pinData: rawPinData }) => {
let pinDataPerWorkflow: OldPinnedData | NewPinnedData;
if (typeof rawPinData === 'string') {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
pinDataPerWorkflow = JSON.parse(rawPinData);
} catch {
pinDataPerWorkflow = {};
}
} else {
pinDataPerWorkflow = rawPinData;
}
const newPinDataPerWorkflow = Object.keys(pinDataPerWorkflow).reduce<NewPinnedData>(
(newPinDataPerWorkflow, nodeName) => {
let pinDataPerNode = pinDataPerWorkflow[nodeName];
if (!Array.isArray(pinDataPerNode)) {
pinDataPerNode = [pinDataPerNode];
}
if (pinDataPerNode.every((item) => item.json)) return newPinDataPerWorkflow;
newPinDataPerWorkflow[nodeName] = pinDataPerNode.map((item) =>
isJsonKeyObject(item) ? item : { json: item },
);
return newPinDataPerWorkflow;
},
{},
);
if (Object.keys(newPinDataPerWorkflow).length > 0) {
updateParams.push({ id, pinData: JSON.stringify(newPinDataPerWorkflow) });
}
return updateParams;
}, []);
}
}
@@ -0,0 +1,28 @@
import { v4 as uuidv4 } from 'uuid';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
type Workflow = { id: number };
export class AddWorkflowVersionIdColumn1669739707124 implements ReversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('workflow_entity');
const columnName = escape.columnName('versionId');
await runQuery(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} CHAR(36)`);
const workflowIds: Workflow[] = await runQuery(`SELECT id FROM ${tableName}`);
for (const { id } of workflowIds) {
await runQuery(`UPDATE ${tableName} SET ${columnName} = :versionId WHERE id = :id`, {
versionId: uuidv4(),
id,
});
}
}
async down({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('workflow_entity');
const columnName = escape.columnName('versionId');
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`);
}
}
@@ -0,0 +1,62 @@
import { StatisticsNames } from '../../entities/types-db';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class RemoveWorkflowDataLoadedFlag1671726148419 implements ReversibleMigration {
async up({ escape, dbType, runQuery }: MigrationContext) {
const workflowTableName = escape.tableName('workflow_entity');
const statisticsTableName = escape.tableName('workflow_statistics');
const columnName = escape.columnName('dataLoaded');
// If any existing workflow has dataLoaded set to true, insert the relevant information to the statistics table
const workflowIds: Array<{ id: number; dataLoaded: boolean }> = await runQuery(
`SELECT id, ${columnName} FROM ${workflowTableName}`,
);
const now =
dbType === 'sqlite' ? "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')" : 'CURRENT_TIMESTAMP(3)';
await Promise.all(
workflowIds.map(
async ({ id, dataLoaded }) =>
await (dataLoaded &&
runQuery(
`INSERT INTO ${statisticsTableName}
(${escape.columnName('workflowId')}, name, count, ${escape.columnName('latestEvent')})
VALUES (:id, :name, 1, ${now})`,
{ id, name: StatisticsNames.dataLoaded },
)),
),
);
await runQuery(`ALTER TABLE ${workflowTableName} DROP COLUMN ${columnName}`);
}
async down({ escape, runQuery }: MigrationContext) {
const workflowTableName = escape.tableName('workflow_entity');
const statisticsTableName = escape.tableName('workflow_statistics');
const columnName = escape.columnName('dataLoaded');
await runQuery(
`ALTER TABLE ${workflowTableName} ADD COLUMN ${columnName} BOOLEAN DEFAULT false`,
);
// Search through statistics for any workflows that have the dataLoaded stat
const workflowsIds: Array<{ workflowId: string }> = await runQuery(
`SELECT ${escape.columnName('workflowId')} FROM ${statisticsTableName} WHERE name = :name`,
{ name: StatisticsNames.dataLoaded },
);
await Promise.all(
workflowsIds.map(
async ({ workflowId }) =>
await runQuery(`UPDATE ${workflowTableName} SET ${columnName} = true WHERE id = :id`, {
id: workflowId,
}),
),
);
await runQuery(`DELETE FROM ${statisticsTableName} WHERE name = :name`, {
name: StatisticsNames.dataLoaded,
});
}
}
@@ -0,0 +1,68 @@
import { LDAP_FEATURE_NAME, LDAP_DEFAULT_CONFIGURATION } from '@n8n/constants';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class CreateLdapEntities1674509946020 implements ReversibleMigration {
async up({ escape, dbType, runQuery }: MigrationContext) {
const userTable = escape.tableName('user');
await runQuery(`ALTER TABLE ${userTable} ADD COLUMN disabled BOOLEAN NOT NULL DEFAULT false;`);
await runQuery(`
INSERT INTO ${escape.tableName('settings')}
(${escape.columnName('key')}, value, ${escape.columnName('loadOnStartup')})
VALUES ('${LDAP_FEATURE_NAME}', '${JSON.stringify(LDAP_DEFAULT_CONFIGURATION)}', true)
`);
const uuidColumnType = dbType === 'postgresdb' ? 'UUID' : 'VARCHAR(36)';
await runQuery(
`CREATE TABLE IF NOT EXISTS ${escape.tableName('auth_identity')} (
${escape.columnName('userId')} ${uuidColumnType} REFERENCES ${userTable} (id),
${escape.columnName('providerId')} VARCHAR(64) NOT NULL,
${escape.columnName('providerType')} VARCHAR(32) NOT NULL,
${escape.columnName('createdAt')} timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
${escape.columnName('updatedAt')} timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(${escape.columnName('providerId')}, ${escape.columnName('providerType')})
)`,
);
const idColumn =
dbType === 'sqlite'
? 'INTEGER PRIMARY KEY AUTOINCREMENT'
: dbType === 'postgresdb'
? 'SERIAL NOT NULL PRIMARY KEY'
: 'INTEGER NOT NULL AUTO_INCREMENT';
const timestampColumn =
dbType === 'sqlite'
? 'DATETIME NOT NULL'
: dbType === 'postgresdb'
? 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP'
: 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP';
await runQuery(
`CREATE TABLE IF NOT EXISTS ${escape.tableName('auth_provider_sync_history')} (
${escape.columnName('id')} ${idColumn},
${escape.columnName('providerType')} VARCHAR(32) NOT NULL,
${escape.columnName('runMode')} TEXT NOT NULL,
${escape.columnName('status')} TEXT NOT NULL,
${escape.columnName('startedAt')} ${timestampColumn},
${escape.columnName('endedAt')} ${timestampColumn},
${escape.columnName('scanned')} INTEGER NOT NULL,
${escape.columnName('created')} INTEGER NOT NULL,
${escape.columnName('updated')} INTEGER NOT NULL,
${escape.columnName('disabled')} INTEGER NOT NULL,
${escape.columnName('error')} TEXT
)`,
);
}
async down({ escape, runQuery }: MigrationContext) {
await runQuery(`DROP TABLE "${escape.tableName('auth_provider_sync_history')}`);
await runQuery(`DROP TABLE "${escape.tableName('auth_identity')}`);
await runQuery(`DELETE FROM ${escape.tableName('settings')} WHERE key = :key`, {
key: LDAP_FEATURE_NAME,
});
await runQuery(`ALTER TABLE ${escape.tableName('user')} DROP COLUMN disabled`);
}
}
@@ -0,0 +1,16 @@
import { UserError } from 'n8n-workflow';
import { WorkflowEntity } from '../../entities';
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
export class PurgeInvalidWorkflowConnections1675940580449 implements IrreversibleMigration {
async up({ queryRunner }: MigrationContext) {
const workflowCount = await queryRunner.manager.count(WorkflowEntity);
if (workflowCount > 0) {
throw new UserError(
'Migration "PurgeInvalidWorkflowConnections1675940580449" is no longer supported. Please upgrade to n8n@1.0.0 first.',
);
}
}
}
@@ -0,0 +1,14 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class RemoveResetPasswordColumns1690000000030 implements ReversibleMigration {
async up({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns('user', ['resetPasswordToken', 'resetPasswordTokenExpiration']);
}
async down({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns('user', [
column('resetPasswordToken').varchar(),
column('resetPasswordTokenExpiration').int,
]);
}
}
@@ -0,0 +1,15 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddMfaColumns1690000000030 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns('user', [
column('mfaEnabled').bool.notNull.default(false),
column('mfaSecret').text,
column('mfaRecoveryCodes').text,
]);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns('user', ['mfaEnabled', 'mfaSecret', 'mfaRecoveryCodes']);
}
}
@@ -0,0 +1,11 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class CreateWorkflowNameIndex1691088862123 implements ReversibleMigration {
async up({ schemaBuilder: { createIndex } }: MigrationContext) {
await createIndex('workflow_entity', ['name']);
}
async down({ schemaBuilder: { dropIndex } }: MigrationContext) {
await dropIndex('workflow_entity', ['name']);
}
}
@@ -0,0 +1,26 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const tableName = 'workflow_history';
export class CreateWorkflowHistoryTable1692967111175 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(tableName)
.withColumns(
column('versionId').varchar(36).primary.notNull,
column('workflowId').varchar(36).notNull,
column('nodes').text.notNull,
column('connections').text.notNull,
column('authors').varchar(255).notNull,
)
.withTimestamps.withIndexOn('workflowId')
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE',
});
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(tableName);
}
}
@@ -0,0 +1,19 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
/**
* Add an indexed column `deletedAt` to track soft-deleted executions.
* Add an index on `stoppedAt`, used by executions pruning.
*/
export class ExecutionSoftDelete1693491613982 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column, createIndex } }: MigrationContext) {
await addColumns('execution_entity', [column('deletedAt').timestamp()]);
await createIndex('execution_entity', ['deletedAt']);
await createIndex('execution_entity', ['stoppedAt']);
}
async down({ schemaBuilder: { dropColumns, dropIndex } }: MigrationContext) {
await dropIndex('execution_entity', ['stoppedAt']);
await dropIndex('execution_entity', ['deletedAt']);
await dropColumns('execution_entity', ['deletedAt']);
}
}
@@ -0,0 +1,22 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class DisallowOrphanExecutions1693554410387 implements ReversibleMigration {
/**
* Ensure all executions point to a workflow.
*/
async up({ escape, schemaBuilder: { addNotNull }, runQuery }: MigrationContext) {
const executionEntity = escape.tableName('execution_entity');
const workflowId = escape.columnName('workflowId');
await runQuery(`DELETE FROM ${executionEntity} WHERE ${workflowId} IS NULL;`);
await addNotNull('execution_entity', 'workflowId');
}
/**
* Reversal excludes restoring deleted rows.
*/
async down({ schemaBuilder: { dropNotNull } }: MigrationContext) {
await dropNotNull('execution_entity', 'workflowId');
}
}
@@ -0,0 +1,11 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddWorkflowMetadata1695128658538 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns('workflow_entity', [column('meta').json]);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns('workflow_entity', ['meta']);
}
}
@@ -0,0 +1,15 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const tableName = 'workflow_history';
export class ModifyWorkflowHistoryNodesAndConnections1695829275184 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, dropColumns, column } }: MigrationContext) {
await dropColumns(tableName, ['nodes', 'connections']);
await addColumns(tableName, [column('nodes').json.notNull, column('connections').json.notNull]);
}
async down({ schemaBuilder: { dropColumns, addColumns, column } }: MigrationContext) {
await dropColumns(tableName, ['nodes', 'connections']);
await addColumns(tableName, [column('nodes').text.notNull, column('connections').text.notNull]);
}
}
@@ -0,0 +1,60 @@
import { UnexpectedError } from 'n8n-workflow';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddGlobalAdminRole1700571993961 implements ReversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('role');
await runQuery(`INSERT INTO ${tableName} (name, scope) VALUES (:name, :scope)`, {
name: 'admin',
scope: 'global',
});
}
async down({ escape, runQuery }: MigrationContext) {
const roleTableName = escape.tableName('role');
const userTableName = escape.tableName('user');
const adminRoleIdResult = await runQuery<Array<{ id: number }>>(
`SELECT id FROM ${roleTableName} WHERE name = :name AND scope = :scope`,
{
name: 'admin',
scope: 'global',
},
);
const memberRoleIdResult = await runQuery<Array<{ id: number }>>(
`SELECT id FROM ${roleTableName} WHERE name = :name AND scope = :scope`,
{
name: 'member',
scope: 'global',
},
);
const adminRoleId = adminRoleIdResult[0]?.id;
if (adminRoleId === undefined) {
// Couldn't find admin role. It's a bit odd but it means we don't
// have anything to do.
return;
}
const memberRoleId = memberRoleIdResult[0]?.id;
if (!memberRoleId) {
throw new UnexpectedError('Could not find global member role!');
}
await runQuery(
`UPDATE ${userTableName} SET globalRoleId = :memberRoleId WHERE globalRoleId = :adminRoleId`,
{
memberRoleId,
adminRoleId,
},
);
await runQuery(`DELETE FROM ${roleTableName} WHERE name = :name AND scope = :scope`, {
name: 'admin',
scope: 'global',
});
}
}
@@ -0,0 +1,131 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
type Table = 'user' | 'shared_workflow' | 'shared_credentials';
const idColumns: Record<Table, string> = {
user: 'id',
shared_credentials: 'credentialsId',
shared_workflow: 'workflowId',
};
const uidColumns: Record<Table, string> = {
user: 'id',
shared_credentials: 'userId',
shared_workflow: 'userId',
};
const roleScopes: Record<Table, string> = {
user: 'global',
shared_credentials: 'credential',
shared_workflow: 'workflow',
};
const foreignKeySuffixes: Record<Table, string> = {
user: 'f0609be844f9200ff4365b1bb3d',
shared_credentials: 'c68e056637562000b68f480815a',
shared_workflow: '3540da03964527aa24ae014b780',
};
export class DropRoleMapping1705429061930 implements ReversibleMigration {
async up(context: MigrationContext) {
await this.migrateUp('user', context);
await this.migrateUp('shared_workflow', context);
await this.migrateUp('shared_credentials', context);
}
async down(context: MigrationContext) {
await this.migrateDown('shared_workflow', context);
await this.migrateDown('shared_credentials', context);
await this.migrateDown('user', context);
}
private async migrateUp(
table: Table,
{
escape,
runQuery,
schemaBuilder: { addNotNull, addColumns, dropColumns, dropForeignKey, column },
tablePrefix,
}: MigrationContext,
) {
await addColumns(table, [column('role').text]);
const roleTable = escape.tableName('role');
const tableName = escape.tableName(table);
const idColumn = escape.columnName(idColumns[table]);
const uidColumn = escape.columnName(uidColumns[table]);
const roleColumnName = table === 'user' ? 'globalRoleId' : 'roleId';
const roleColumn = escape.columnName(roleColumnName);
const scope = roleScopes[table];
const roleField = `'${scope}:' || R.name`;
const subQuery = `
SELECT ${roleField} as role, T.${idColumn} as id${
table !== 'user' ? `, T.${uidColumn} as uid` : ''
}
FROM ${tableName} T
LEFT JOIN ${roleTable} R
ON T.${roleColumn} = R.id and R.scope = '${scope}'`;
const where = `WHERE ${tableName}.${idColumn} = mapping.id${
table !== 'user' ? ` AND ${tableName}.${uidColumn} = mapping.uid` : ''
}`;
const swQuery = `UPDATE ${tableName}
SET role = mapping.role
FROM (${subQuery}) as mapping
${where}`;
await runQuery(swQuery);
await addNotNull(table, 'role');
await dropForeignKey(
table,
roleColumnName,
['role', 'id'],
`FK_${tablePrefix}${foreignKeySuffixes[table]}`,
);
await dropColumns(table, [roleColumnName]);
}
private async migrateDown(
table: Table,
{
escape,
runQuery,
schemaBuilder: { addNotNull, addColumns, dropColumns, addForeignKey, column },
tablePrefix,
}: MigrationContext,
) {
const roleColumnName = table === 'user' ? 'globalRoleId' : 'roleId';
await addColumns(table, [column(roleColumnName).int]);
const roleTable = escape.tableName('role');
const tableName = escape.tableName(table);
const idColumn = escape.columnName(idColumns[table]);
const uidColumn = escape.columnName(uidColumns[table]);
const roleColumn = escape.columnName(roleColumnName);
const scope = roleScopes[table];
const roleField = `'${scope}:' || R.name`;
const subQuery = `
SELECT R.id as role_id, T.${idColumn} as id${table !== 'user' ? `, T.${uidColumn} as uid` : ''}
FROM ${tableName} T
LEFT JOIN ${roleTable} R
ON T.role = ${roleField} and R.scope = '${scope}'`;
const where = `WHERE ${tableName}.${idColumn} = mapping.id${
table !== 'user' ? ` AND ${tableName}.${uidColumn} = mapping.uid` : ''
}`;
const query = `UPDATE ${tableName}
SET ${roleColumn} = mapping.role_id
FROM (${subQuery}) as mapping
${where}`;
await runQuery(query);
await addNotNull(table, roleColumnName);
await addForeignKey(
table,
roleColumnName,
['role', 'id'],
`FK_${tablePrefix}${foreignKeySuffixes[table]}`,
);
await dropColumns(table, ['role']);
}
}
@@ -0,0 +1,9 @@
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
export class RemoveFailedExecutionStatus1711018413374 implements IrreversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const executionEntity = escape.tableName('execution_entity');
await runQuery(`UPDATE ${executionEntity} SET status = 'error' WHERE status = 'failed';`);
}
}
@@ -0,0 +1,121 @@
import { Container } from '@n8n/di';
import { Cipher, InstanceSettings } from 'n8n-core';
import { jsonParse } from 'n8n-workflow';
import { readFile, writeFile, rm } from 'node:fs/promises';
import path from 'node:path';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
/**
* Move SSH key pair from file system to database, to enable SSH connections
* when running n8n in multiple containers - mains, webhooks, workers, etc.
*/
export class MoveSshKeysToDatabase1711390882123 implements ReversibleMigration {
private readonly settingsKey = 'features.sourceControl.sshKeys';
private readonly privateKeyPath = path.join(
Container.get(InstanceSettings).n8nFolder,
'ssh',
'key',
);
private readonly publicKeyPath = this.privateKeyPath + '.pub';
private readonly cipher = Container.get(Cipher);
async up({ escape, runQuery, logger, migrationName }: MigrationContext) {
let privateKey, publicKey;
try {
[privateKey, publicKey] = await Promise.all([
readFile(this.privateKeyPath, { encoding: 'utf8' }),
readFile(this.publicKeyPath, { encoding: 'utf8' }),
]);
} catch {
logger.info(`[${migrationName}] No SSH keys in filesystem, skipping`);
return;
}
if (!privateKey && !publicKey) {
logger.info(`[${migrationName}] No SSH keys in filesystem, skipping`);
return;
}
const settings = escape.tableName('settings');
const key = escape.columnName('key');
const value = escape.columnName('value');
const rows: Array<{ value: string }> = await runQuery(
`SELECT value FROM ${settings} WHERE ${key} = '${this.settingsKey}';`,
);
if (rows.length === 1) {
logger.info(`[${migrationName}] SSH keys already in database, skipping`);
return;
}
if (!privateKey) {
logger.error(`[${migrationName}] No private key found, skipping`);
return;
}
const settingsValue = JSON.stringify({
encryptedPrivateKey: this.cipher.encrypt(privateKey),
publicKey,
});
await runQuery(
`INSERT INTO ${settings} (${key}, ${value}) VALUES ('${this.settingsKey}', '${settingsValue}');`,
);
try {
await Promise.all([rm(this.privateKeyPath), rm(this.publicKeyPath)]);
} catch (e) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
const error = e instanceof Error ? e : new Error(`${e}`);
logger.error(
`[${migrationName}] Failed to remove SSH keys from filesystem: ${error.message}`,
);
}
}
async down({ escape, runQuery, logger, migrationName }: MigrationContext) {
const settings = escape.tableName('settings');
const key = escape.columnName('key');
const rows: Array<{ value: string }> = await runQuery(
`SELECT value FROM ${settings} WHERE ${key} = '${this.settingsKey}';`,
);
if (rows.length !== 1) {
logger.info(`[${migrationName}] No SSH keys in database, skipping revert`);
return;
}
const [row] = rows;
type KeyPair = { publicKey: string; encryptedPrivateKey: string };
const dbKeyPair = jsonParse<KeyPair | null>(row.value, { fallbackValue: null });
if (!dbKeyPair) {
logger.info(`[${migrationName}] Malformed SSH keys in database, skipping revert`);
return;
}
const privateKey = this.cipher.decrypt(dbKeyPair.encryptedPrivateKey);
const { publicKey } = dbKeyPair;
try {
await Promise.all([
writeFile(this.privateKeyPath, privateKey, { encoding: 'utf8', mode: 0o600 }),
writeFile(this.publicKeyPath, publicKey, { encoding: 'utf8', mode: 0o600 }),
]);
} catch {
logger.error(`[${migrationName}] Failed to write SSH keys to filesystem, skipping revert`);
return;
}
await runQuery(`DELETE FROM ${settings} WHERE ${key} = 'features.sourceControl.sshKeys';`);
}
}
@@ -0,0 +1,7 @@
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
export class RemoveNodesAccess1712044305787 implements IrreversibleMigration {
async up({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns('credentials_entity', ['nodesAccess']);
}
}
@@ -0,0 +1,315 @@
import type { ProjectRole } from '@n8n/permissions';
import { generateNanoId } from '@n8n/utils';
import { UserError } from 'n8n-workflow';
import type { User } from '../../entities';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const projectAdminRole: ProjectRole = 'project:personalOwner';
type RelationTable = 'shared_workflow' | 'shared_credentials';
const table = {
sharedCredentials: 'shared_credentials',
sharedCredentialsTemp: 'shared_credentials_2',
sharedWorkflow: 'shared_workflow',
sharedWorkflowTemp: 'shared_workflow_2',
project: 'project',
user: 'user',
projectRelation: 'project_relation',
} as const;
function escapeNames(escape: MigrationContext['escape']) {
const t = {
project: escape.tableName(table.project),
projectRelation: escape.tableName(table.projectRelation),
sharedCredentials: escape.tableName(table.sharedCredentials),
sharedCredentialsTemp: escape.tableName(table.sharedCredentialsTemp),
sharedWorkflow: escape.tableName(table.sharedWorkflow),
sharedWorkflowTemp: escape.tableName(table.sharedWorkflowTemp),
user: escape.tableName(table.user),
};
const c = {
createdAt: escape.columnName('createdAt'),
updatedAt: escape.columnName('updatedAt'),
workflowId: escape.columnName('workflowId'),
credentialsId: escape.columnName('credentialsId'),
userId: escape.columnName('userId'),
projectId: escape.columnName('projectId'),
firstName: escape.columnName('firstName'),
lastName: escape.columnName('lastName'),
};
return { t, c };
}
export class CreateProject1714133768519 implements ReversibleMigration {
async setupTables({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(table.project).withColumns(
column('id').varchar(36).primary.notNull,
column('name').varchar(255).notNull,
column('type').varchar(36).notNull,
).withTimestamps;
await createTable(table.projectRelation)
.withColumns(
column('projectId').varchar(36).primary.notNull,
column('userId').uuid.primary.notNull,
column('role').varchar().notNull,
)
.withIndexOn('projectId')
.withIndexOn('userId')
.withForeignKey('projectId', {
tableName: table.project,
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
}
async alterSharedTable(
relationTableName: RelationTable,
{
escape,
runQuery,
schemaBuilder: { addForeignKey, addColumns, addNotNull, createIndex, column },
}: MigrationContext,
) {
const projectIdColumn = column('projectId').varchar(36).default('NULL');
await addColumns(relationTableName, [projectIdColumn]);
const relationTable = escape.tableName(relationTableName);
const { t, c } = escapeNames(escape);
// Populate projectId
const subQuery = `
SELECT P.id as ${c.projectId}, T.${c.userId}
FROM ${t.projectRelation} T
LEFT JOIN ${t.project} P
ON T.${c.projectId} = P.id AND P.type = 'personal'
LEFT JOIN ${relationTable} S
ON T.${c.userId} = S.${c.userId}
WHERE P.id IS NOT NULL
`;
const swQuery = `UPDATE ${relationTable}
SET ${c.projectId} = mapping.${c.projectId}
FROM (${subQuery}) as mapping
WHERE ${relationTable}.${c.userId} = mapping.${c.userId}`;
await runQuery(swQuery);
await addForeignKey(relationTableName, 'projectId', ['project', 'id']);
await addNotNull(relationTableName, 'projectId');
// Index the new projectId column
await createIndex(relationTableName, ['projectId']);
}
async alterSharedCredentials({
escape,
runQuery,
schemaBuilder: { column, createTable, dropTable },
}: MigrationContext) {
await createTable(table.sharedCredentialsTemp)
.withColumns(
column('credentialsId').varchar(36).notNull.primary,
column('projectId').varchar(36).notNull.primary,
column('role').text.notNull,
)
.withForeignKey('credentialsId', {
tableName: 'credentials_entity',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('projectId', {
tableName: table.project,
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
const { c, t } = escapeNames(escape);
await runQuery(`
INSERT INTO ${t.sharedCredentialsTemp} (${c.createdAt}, ${c.updatedAt}, ${c.credentialsId}, ${c.projectId}, role)
SELECT ${c.createdAt}, ${c.updatedAt}, ${c.credentialsId}, ${c.projectId}, role FROM ${t.sharedCredentials};
`);
await dropTable(table.sharedCredentials);
await runQuery(`ALTER TABLE ${t.sharedCredentialsTemp} RENAME TO ${t.sharedCredentials};`);
}
async alterSharedWorkflow({
escape,
runQuery,
schemaBuilder: { column, createTable, dropTable },
}: MigrationContext) {
await createTable(table.sharedWorkflowTemp)
.withColumns(
column('workflowId').varchar(36).notNull.primary,
column('projectId').varchar(36).notNull.primary,
column('role').text.notNull,
)
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('projectId', {
tableName: table.project,
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
const { c, t } = escapeNames(escape);
await runQuery(`
INSERT INTO ${t.sharedWorkflowTemp} (${c.createdAt}, ${c.updatedAt}, ${c.workflowId}, ${c.projectId}, role)
SELECT ${c.createdAt}, ${c.updatedAt}, ${c.workflowId}, ${c.projectId}, role FROM ${t.sharedWorkflow};
`);
await dropTable(table.sharedWorkflow);
await runQuery(`ALTER TABLE ${t.sharedWorkflowTemp} RENAME TO ${t.sharedWorkflow};`);
}
async createUserPersonalProjects({ runQuery, runInBatches, escape }: MigrationContext) {
const { c, t } = escapeNames(escape);
const getUserQuery = `SELECT id, ${c.firstName}, ${c.lastName}, email FROM ${t.user}`;
await runInBatches<Pick<User, 'id' | 'firstName' | 'lastName' | 'email'>>(
getUserQuery,
async (users) => {
await Promise.all(
users.map(async (user) => {
const projectId = generateNanoId();
const name = this.createPersonalProjectName(user.firstName, user.lastName, user.email);
await runQuery(
`INSERT INTO ${t.project} (id, type, name) VALUES (:projectId, 'personal', :name)`,
{
projectId,
name,
},
);
await runQuery(
`INSERT INTO ${t.projectRelation} (${c.projectId}, ${c.userId}, role) VALUES (:projectId, :userId, :projectRole)`,
{
projectId,
userId: user.id,
projectRole: projectAdminRole,
},
);
}),
);
},
);
}
// Duplicated from packages/@n8n/db/src/entities/User.ts
// Reason:
// This migration should work the same even if we refactor the function in
// `User.ts`.
createPersonalProjectName(firstName?: string, lastName?: string, email?: string) {
if (firstName && lastName && email) {
return `${firstName} ${lastName} <${email}>`;
} else if (email) {
return `<${email}>`;
} else {
return 'Unnamed Project';
}
}
async up(context: MigrationContext) {
await this.setupTables(context);
await this.createUserPersonalProjects(context);
await this.alterSharedTable(table.sharedCredentials, context);
await this.alterSharedCredentials(context);
await this.alterSharedTable(table.sharedWorkflow, context);
await this.alterSharedWorkflow(context);
}
async down({ logger, escape, runQuery, schemaBuilder: sb }: MigrationContext) {
const { t, c } = escapeNames(escape);
// 0. check if all projects are personal projects
const [{ count: nonPersonalProjects }] = await runQuery<[{ count: number }]>(
`SELECT COUNT(*) FROM ${t.project} WHERE type <> 'personal';`,
);
if (nonPersonalProjects > 0) {
const message =
'Down migration only possible when there are no projects. Please delete all projects that were created via the UI first.';
logger.error(message);
throw new UserError(message);
}
// 1. create temp table for shared workflows
await sb
.createTable(table.sharedWorkflowTemp)
.withColumns(
sb.column('workflowId').varchar(36).notNull.primary,
sb.column('userId').uuid.notNull.primary,
sb.column('role').text.notNull,
)
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE',
name: undefined,
})
.withForeignKey('userId', {
tableName: table.user,
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
// 2. migrate data into temp table
await runQuery(`
INSERT INTO ${t.sharedWorkflowTemp} (${c.createdAt}, ${c.updatedAt}, ${c.workflowId}, role, ${c.userId})
SELECT SW.${c.createdAt}, SW.${c.updatedAt}, SW.${c.workflowId}, SW.role, PR.${c.userId}
FROM ${t.sharedWorkflow} SW
LEFT JOIN project_relation PR on SW.${c.projectId} = PR.${c.projectId} AND PR.role = 'project:personalOwner'
`);
// 3. drop shared workflow table
await sb.dropTable(table.sharedWorkflow);
// 4. rename temp table
await runQuery(`ALTER TABLE ${t.sharedWorkflowTemp} RENAME TO ${t.sharedWorkflow};`);
// 5. same for shared creds
await sb
.createTable(table.sharedCredentialsTemp)
.withColumns(
sb.column('credentialsId').varchar(36).notNull.primary,
sb.column('userId').uuid.notNull.primary,
sb.column('role').text.notNull,
)
.withForeignKey('credentialsId', {
tableName: 'credentials_entity',
columnName: 'id',
onDelete: 'CASCADE',
name: undefined,
})
.withForeignKey('userId', {
tableName: table.user,
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
await runQuery(`
INSERT INTO ${t.sharedCredentialsTemp} (${c.createdAt}, ${c.updatedAt}, ${c.credentialsId}, role, ${c.userId})
SELECT SC.${c.createdAt}, SC.${c.updatedAt}, SC.${c.credentialsId}, SC.role, PR.${c.userId}
FROM ${t.sharedCredentials} SC
LEFT JOIN project_relation PR on SC.${c.projectId} = PR.${c.projectId} AND PR.role = 'project:personalOwner'
`);
await sb.dropTable(table.sharedCredentials);
await runQuery(`ALTER TABLE ${t.sharedCredentialsTemp} RENAME TO ${t.sharedCredentials};`);
// 6. drop project and project relation table
await sb.dropTable(table.projectRelation);
await sb.dropTable(table.project);
}
}
@@ -0,0 +1,22 @@
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
export class MakeExecutionStatusNonNullable1714133768521 implements IrreversibleMigration {
async up({ escape, runQuery, schemaBuilder }: MigrationContext) {
const executionEntity = escape.tableName('execution_entity');
const status = escape.columnName('status');
const finished = escape.columnName('finished');
const query = `
UPDATE ${executionEntity}
SET ${status} = CASE
WHEN ${finished} = true THEN 'success'
WHEN ${finished} = false THEN 'error'
END
WHERE ${status} IS NULL;
`;
await runQuery(query);
await schemaBuilder.addNotNull('execution_entity', 'status');
}
}
@@ -0,0 +1,102 @@
import { nanoid } from 'nanoid';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddConstraintToExecutionMetadata1720101653148 implements ReversibleMigration {
async up(context: MigrationContext) {
const { createTable, dropTable, column } = context.schemaBuilder;
const { escape } = context;
const executionMetadataTableRaw = 'execution_metadata';
const executionMetadataTable = escape.tableName(executionMetadataTableRaw);
const executionMetadataTableTempRaw = 'execution_metadata_temp';
const executionMetadataTableTemp = escape.tableName(executionMetadataTableTempRaw);
const id = escape.columnName('id');
const executionId = escape.columnName('executionId');
const key = escape.columnName('key');
const value = escape.columnName('value');
await createTable(executionMetadataTableTempRaw)
.withColumns(
column('id').int.notNull.primary.autoGenerate,
column('executionId').int.notNull,
column('key').varchar(255).notNull,
column('value').text.notNull,
)
.withForeignKey('executionId', {
tableName: 'execution_entity',
columnName: 'id',
onDelete: 'CASCADE',
name: undefined,
})
.withIndexOn(['executionId', 'key'], true);
await context.runQuery(`
INSERT INTO ${executionMetadataTableTemp} (${id}, ${executionId}, ${key}, ${value})
SELECT MAX(${id}) as ${id}, ${executionId}, ${key}, MAX(${value})
FROM ${executionMetadataTable}
GROUP BY ${executionId}, ${key}
ON CONFLICT (${executionId}, ${key}) DO UPDATE SET
id = EXCLUDED.id,
value = EXCLUDED.value
WHERE EXCLUDED.id > ${executionMetadataTableTemp}.id;
`);
await dropTable(executionMetadataTableRaw);
await context.runQuery(
`ALTER TABLE ${executionMetadataTableTemp} RENAME TO ${executionMetadataTable};`,
);
}
async down(context: MigrationContext) {
const { createTable, dropTable, column } = context.schemaBuilder;
const { escape } = context;
const executionMetadataTableRaw = 'execution_metadata';
const executionMetadataTable = escape.tableName(executionMetadataTableRaw);
const executionMetadataTableTempRaw = 'execution_metadata_temp';
const executionMetadataTableTemp = escape.tableName(executionMetadataTableTempRaw);
const id = escape.columnName('id');
const executionId = escape.columnName('executionId');
const key = escape.columnName('key');
const value = escape.columnName('value');
await createTable(executionMetadataTableTempRaw)
.withColumns(
// INFO: The PK names that TypeORM creates are predictable and thus it
// will create a PK name which already exists in the current
// execution_metadata table. That's why we have to randomize the PK name
// here.
column('id').int.notNull.primaryWithName(nanoid()).autoGenerate,
column('executionId').int.notNull,
column('key').text.notNull,
column('value').text.notNull,
)
.withForeignKey('executionId', {
tableName: 'execution_entity',
columnName: 'id',
onDelete: 'CASCADE',
name: undefined,
});
await context.runQuery(`
INSERT INTO ${executionMetadataTableTemp} (${id}, ${executionId}, ${key}, ${value})
SELECT ${id}, ${executionId}, ${key}, ${value} FROM ${executionMetadataTable};
`);
await dropTable(executionMetadataTableRaw);
await context.runQuery(
`ALTER TABLE ${executionMetadataTableTemp} RENAME TO ${executionMetadataTable};`,
);
if (context.dbType === 'postgresdb') {
// Update sequence so that inserts continue with the next highest id.
const tableName = escape.tableName('execution_metadata');
const sequenceName = escape.tableName('execution_metadata_temp_id_seq1');
await context.runQuery(
`SELECT setval('${sequenceName}', (SELECT MAX(id) FROM ${tableName}));`,
);
}
}
}
@@ -0,0 +1,16 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const tableName = 'invalid_auth_token';
export class CreateInvalidAuthTokenTable1723627610222 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(tableName).withColumns(
column('token').varchar(512).primary,
column('expiresAt').timestamp().notNull,
);
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(tableName);
}
}
@@ -0,0 +1,102 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
/**
* Add new indices:
*
* - `workflowId, startedAt` for `ExecutionRepository.findManyByRangeQuery` (default query) and for `ExecutionRepository.findManyByRangeQuery` (filter query)
* - `waitTill, status, deletedAt` for `ExecutionRepository.getWaitingExecutions`
* - `stoppedAt, status, deletedAt` for `ExecutionRepository.softDeletePrunableExecutions`
*
* Remove unused indices in sqlite:
*
* - `stoppedAt` (duplicate with different casing)
* - `waitTill`
* - `status, workflowId`
*
* Remove unused indices in all DBs:
*
* - `waitTill, id`
* - `workflowId, id`
*
* Remove incomplete index in all DBs:
*
* - `stopped_at` (replaced with composite index)
*
* Keep index as is:
*
* - `deletedAt` for query at `ExecutionRepository.hardDeleteSoftDeletedExecutions`
*/
export class RefactorExecutionIndices1723796243146 implements ReversibleMigration {
async up({ schemaBuilder, isPostgres, isSqlite, runQuery, escape }: MigrationContext) {
if (isSqlite || isPostgres) {
const executionEntity = escape.tableName('execution_entity');
const workflowId = escape.columnName('workflowId');
const startedAt = escape.columnName('startedAt');
const waitTill = escape.columnName('waitTill');
const status = escape.columnName('status');
const deletedAt = escape.columnName('deletedAt');
const stoppedAt = escape.columnName('stoppedAt');
await runQuery(`
CREATE INDEX idx_execution_entity_workflow_id_started_at
ON ${executionEntity} (${workflowId}, ${startedAt})
WHERE ${startedAt} IS NOT NULL AND ${deletedAt} IS NULL;
`);
await runQuery(`
CREATE INDEX idx_execution_entity_wait_till_status_deleted_at
ON ${executionEntity} (${waitTill}, ${status}, ${deletedAt})
WHERE ${waitTill} IS NOT NULL AND ${deletedAt} IS NULL;
`);
await runQuery(`
CREATE INDEX idx_execution_entity_stopped_at_status_deleted_at
ON ${executionEntity} (${stoppedAt}, ${status}, ${deletedAt})
WHERE ${stoppedAt} IS NOT NULL AND ${deletedAt} IS NULL;
`);
}
if (isSqlite) {
await schemaBuilder.dropIndex('execution_entity', ['waitTill'], {
customIndexName: 'idx_execution_entity_wait_till',
skipIfMissing: true,
});
await schemaBuilder.dropIndex('execution_entity', ['status', 'workflowId'], {
customIndexName: 'IDX_8b6f3f9ae234f137d707b98f3bf43584',
skipIfMissing: true,
});
}
// all DBs
await schemaBuilder.dropIndex(
'execution_entity',
['stoppedAt'],
isSqlite ? { customIndexName: 'idx_execution_entity_stopped_at', skipIfMissing: true } : {},
);
await schemaBuilder.dropIndex('execution_entity', ['waitTill', 'id'], {
customIndexName: isPostgres
? 'IDX_85b981df7b444f905f8bf50747'
: 'IDX_b94b45ce2c73ce46c54f20b5f9',
skipIfMissing: true,
});
await schemaBuilder.dropIndex('execution_entity', ['workflowId', 'id'], {
customIndexName: isPostgres
? 'idx_execution_entity_workflow_id_id'
: 'IDX_81fc04c8a17de15835713505e4',
skipIfMissing: true,
});
}
async down({ schemaBuilder }: MigrationContext) {
await schemaBuilder.dropIndex('execution_entity', ['workflowId', 'startedAt']);
await schemaBuilder.dropIndex('execution_entity', ['waitTill', 'status']);
await schemaBuilder.dropIndex('execution_entity', ['stoppedAt', 'deletedAt', 'status']);
await schemaBuilder.createIndex('execution_entity', ['waitTill', 'id']);
await schemaBuilder.createIndex('execution_entity', ['stoppedAt']);
await schemaBuilder.createIndex('execution_entity', ['workflowId', 'id']);
}
}
@@ -0,0 +1,51 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const annotationsTableName = 'execution_annotations';
const annotationTagsTableName = 'annotation_tag_entity';
const annotationTagMappingsTableName = 'execution_annotation_tags';
export class CreateAnnotationTables1724753530828 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(annotationsTableName)
.withColumns(
column('id').int.notNull.primary.autoGenerate,
column('executionId').int.notNull,
column('vote').varchar(6),
column('note').text,
)
.withIndexOn('executionId', true)
.withForeignKey('executionId', {
tableName: 'execution_entity',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
await createTable(annotationTagsTableName)
.withColumns(column('id').varchar(16).primary.notNull, column('name').varchar(24).notNull)
.withIndexOn('name', true).withTimestamps;
await createTable(annotationTagMappingsTableName)
.withColumns(
column('annotationId').int.notNull.primary,
column('tagId').varchar(24).notNull.primary,
)
.withForeignKey('annotationId', {
tableName: annotationsTableName,
columnName: 'id',
onDelete: 'CASCADE',
})
.withIndexOn('tagId')
.withIndexOn('annotationId')
.withForeignKey('tagId', {
tableName: annotationTagsTableName,
columnName: 'id',
onDelete: 'CASCADE',
});
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(annotationTagMappingsTableName);
await dropTable(annotationTagsTableName);
await dropTable(annotationsTableName);
}
}
@@ -0,0 +1,100 @@
import { generateNanoId } from '@n8n/utils';
import type { ApiKey } from '../../entities';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddApiKeysTable1724951148974 implements ReversibleMigration {
async up({
queryRunner,
escape,
runQuery,
schemaBuilder: { createTable, column },
}: MigrationContext) {
const userTable = escape.tableName('user');
const userApiKeysTable = escape.tableName('user_api_keys');
const userIdColumn = escape.columnName('userId');
const apiKeyColumn = escape.columnName('apiKey');
const labelColumn = escape.columnName('label');
const idColumn = escape.columnName('id');
// Create the new table
await createTable('user_api_keys')
.withColumns(
column('id').varchar(36).primary,
column('userId').uuid.notNull,
column('label').varchar(100).notNull,
column('apiKey').varchar().notNull,
)
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'CASCADE',
})
.withIndexOn(['userId', 'label'], true)
.withIndexOn(['apiKey'], true).withTimestamps;
const usersWithApiKeys = (await queryRunner.query(
`SELECT ${idColumn}, ${apiKeyColumn} FROM ${userTable} WHERE ${apiKeyColumn} IS NOT NULL`,
)) as Array<Partial<ApiKey>>;
// Move the apiKey from the users table to the new table
await Promise.all(
usersWithApiKeys.map(
async (user: { id: string; apiKey: string }) =>
await runQuery(
`INSERT INTO ${userApiKeysTable} (${idColumn}, ${userIdColumn}, ${apiKeyColumn}, ${labelColumn}) VALUES (:id, :userId, :apiKey, :label)`,
{
id: generateNanoId(),
userId: user.id,
apiKey: user.apiKey,
label: 'My API Key',
},
),
),
);
// Drop apiKey column on user's table
await queryRunner.query(`ALTER TABLE ${userTable} DROP COLUMN ${apiKeyColumn};`);
}
async down({
queryRunner,
runQuery,
schemaBuilder: { dropTable, addColumns, createIndex, column },
escape,
}: MigrationContext) {
const userTable = escape.tableName('user');
const userApiKeysTable = escape.tableName('user_api_keys');
const apiKeyColumn = escape.columnName('apiKey');
const userIdColumn = escape.columnName('userId');
const idColumn = escape.columnName('id');
const createdAtColumn = escape.columnName('createdAt');
await addColumns('user', [column('apiKey').varchar()]);
await createIndex('user', ['apiKey'], true);
const queryToGetUsersApiKeys = `
SELECT DISTINCT ON
(${userIdColumn}) ${userIdColumn},
${apiKeyColumn}, ${createdAtColumn}
FROM ${userApiKeysTable}
ORDER BY ${userIdColumn}, ${createdAtColumn} ASC;`;
const oldestApiKeysPerUser = (await queryRunner.query(queryToGetUsersApiKeys)) as Array<
Partial<ApiKey>
>;
await Promise.all(
oldestApiKeysPerUser.map(
async (user: { userId: string; apiKey: string }) =>
await runQuery(
`UPDATE ${userTable} SET ${apiKeyColumn} = :apiKey WHERE ${idColumn} = :userId`,
user,
),
),
);
await dropTable('user_api_keys');
}
}
@@ -0,0 +1,23 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const processedDataTableName = 'processed_data';
export class CreateProcessedDataTable1726606152711 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(processedDataTableName)
.withColumns(
column('workflowId').varchar(36).notNull.primary,
column('value').varchar(255).notNull,
column('context').varchar(255).notNull.primary,
)
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(processedDataTableName);
}
}
@@ -0,0 +1,27 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class SeparateExecutionCreationFromStart1727427440136 implements ReversibleMigration {
async up({
schemaBuilder: { addColumns, column, dropNotNull },
runQuery,
escape,
}: MigrationContext) {
await addColumns('execution_entity', [
column('createdAt').notNull.timestamp().default('NOW()'),
]);
await dropNotNull('execution_entity', 'startedAt');
const executionEntity = escape.tableName('execution_entity');
const createdAt = escape.columnName('createdAt');
const startedAt = escape.columnName('startedAt');
// inaccurate for pre-migration rows but prevents `createdAt` from being nullable
await runQuery(`UPDATE ${executionEntity} SET ${createdAt} = ${startedAt};`);
}
async down({ schemaBuilder: { dropColumns, addNotNull } }: MigrationContext) {
await dropColumns('execution_entity', ['createdAt']);
await addNotNull('execution_entity', 'startedAt');
}
}
@@ -0,0 +1,23 @@
import assert from 'node:assert';
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
export class AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644
implements IrreversibleMigration
{
async up({ queryRunner, tablePrefix }: MigrationContext) {
// Check if the primary key already exists
const table = await queryRunner.getTable(`${tablePrefix}execution_annotation_tags`);
assert(table, 'execution_annotation_tags table not found');
const hasPrimaryKey = table.primaryColumns.length > 0;
if (!hasPrimaryKey) {
await queryRunner.createPrimaryKey(`${tablePrefix}execution_annotation_tags`, [
'annotationId',
'tagId',
]);
}
}
}
@@ -0,0 +1,24 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const processedDataTableName = 'processed_data';
export class UpdateProcessedDataValueColumnToText1729607673464 implements ReversibleMigration {
async up({ schemaBuilder: { addNotNull }, runQuery, tablePrefix }: MigrationContext) {
const prefixedTableName = `${tablePrefix}${processedDataTableName}`;
await runQuery(`ALTER TABLE ${prefixedTableName} ADD COLUMN value_temp TEXT;`);
await runQuery(`UPDATE ${prefixedTableName} SET value_temp = value;`);
await runQuery(`ALTER TABLE ${prefixedTableName} DROP COLUMN value;`);
await runQuery(`ALTER TABLE ${prefixedTableName} RENAME COLUMN value_temp TO value`);
await addNotNull(processedDataTableName, 'value');
}
async down({ schemaBuilder: { addNotNull }, runQuery, tablePrefix }: MigrationContext) {
const prefixedTableName = `${tablePrefix}${processedDataTableName}`;
await runQuery(`ALTER TABLE ${prefixedTableName} ADD COLUMN value_temp VARCHAR(255);`);
await runQuery(`UPDATE ${prefixedTableName} SET value_temp = value;`);
await runQuery(`ALTER TABLE ${prefixedTableName} DROP COLUMN value;`);
await runQuery(`ALTER TABLE ${prefixedTableName} RENAME COLUMN value_temp TO value`);
await addNotNull(processedDataTableName, 'value');
}
}
@@ -0,0 +1,10 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddProjectIcons1729607673469 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns('project', [column('icon').json]);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns('project', ['icon']);
}
}
@@ -0,0 +1,37 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const testEntityTableName = 'test_definition';
export class CreateTestDefinitionTable1730386903556 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(testEntityTableName)
.withColumns(
column('id').int.notNull.primary.autoGenerate,
column('name').varchar(255).notNull,
column('workflowId').varchar(36).notNull,
column('evaluationWorkflowId').varchar(36),
column('annotationTagId').varchar(16),
)
.withIndexOn('workflowId')
.withIndexOn('evaluationWorkflowId')
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('evaluationWorkflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'SET NULL',
})
.withForeignKey('annotationTagId', {
tableName: 'annotation_tag_entity',
columnName: 'id',
onDelete: 'SET NULL',
}).withTimestamps;
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(testEntityTableName);
}
}
@@ -0,0 +1,11 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddDescriptionToTestDefinition1731404028106 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns('test_definition', [column('description').text]);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns('test_definition', ['description']);
}
}
@@ -0,0 +1,24 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const testMetricEntityTableName = 'test_metric';
export class CreateTestMetricTable1732271325258 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(testMetricEntityTableName)
.withColumns(
column('id').varchar(36).primary.notNull,
column('name').varchar(255).notNull,
column('testDefinitionId').varchar(36).notNull,
)
.withIndexOn('testDefinitionId')
.withForeignKey('testDefinitionId', {
tableName: 'test_definition',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(testMetricEntityTableName);
}
}
@@ -0,0 +1,27 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const testRunTableName = 'test_run';
export class CreateTestRun1732549866705 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(testRunTableName)
.withColumns(
column('id').varchar(36).primary.notNull,
column('testDefinitionId').varchar(36).notNull,
column('status').varchar().notNull,
column('runAt').timestamp(),
column('completedAt').timestamp(),
column('metrics').json,
)
.withIndexOn('testDefinitionId')
.withForeignKey('testDefinitionId', {
tableName: 'test_definition',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(testRunTableName);
}
}
@@ -0,0 +1,22 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
// We have to use raw query migration instead of schemaBuilder helpers,
// because the typeorm schema builder implements addColumns by a table recreate for sqlite
// which causes weird issues with the migration
export class AddMockedNodesColumnToTestDefinition1733133775640 implements ReversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('test_definition');
const mockedNodesColumnName = escape.columnName('mockedNodes');
await runQuery(
`ALTER TABLE ${tableName} ADD COLUMN ${mockedNodesColumnName} JSON DEFAULT ('[]') NOT NULL`,
);
}
async down({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('test_definition');
const columnName = escape.columnName('mockedNodes');
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`);
}
}
@@ -0,0 +1,21 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddManagedColumnToCredentialsTable1734479635324 implements ReversibleMigration {
async up({ escape, runQuery, isSqlite }: MigrationContext) {
const tableName = escape.tableName('credentials_entity');
const columnName = escape.columnName('isManaged');
const defaultValue = isSqlite ? 0 : 'FALSE';
await runQuery(
`ALTER TABLE ${tableName} ADD COLUMN ${columnName} BOOLEAN NOT NULL DEFAULT ${defaultValue}`,
);
}
async down({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('credentials_entity');
const columnName = escape.columnName('isManaged');
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`);
}
}
@@ -0,0 +1,31 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const columns = ['totalCases', 'passedCases', 'failedCases'] as const;
export class AddStatsColumnsToTestRun1736172058779 implements ReversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('test_run');
const columnNames = columns.map((name) => escape.columnName(name));
// Values can be NULL only if the test run is new, otherwise they must be non-negative integers.
// Test run might be cancelled or interrupted by unexpected error at any moment, so values can be either NULL or non-negative integers.
for (const name of columnNames) {
await runQuery(`ALTER TABLE ${tableName} ADD COLUMN ${name} INT CHECK(
CASE
WHEN status = 'new' THEN ${name} IS NULL
WHEN status in ('cancelled', 'error') THEN ${name} IS NULL OR ${name} >= 0
ELSE ${name} >= 0
END
)`);
}
}
async down({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('test_run');
const columnNames = columns.map((name) => escape.columnName(name));
for (const name of columnNames) {
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${name}`);
}
}
}
@@ -0,0 +1,47 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const testCaseExecutionTableName = 'test_case_execution';
export class CreateTestCaseExecutionTable1736947513045 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(testCaseExecutionTableName)
.withColumns(
column('id').varchar(36).primary.notNull,
column('testRunId').varchar(36).notNull,
column('pastExecutionId').int, // Might be null if execution was deleted after the test run
column('executionId').int, // Execution of the workflow under test. Might be null if execution was deleted after the test run
column('evaluationExecutionId').int, // Execution of the evaluation workflow. Might be null if execution was deleted after the test run, or if the test run was cancelled
column('status').varchar().notNull,
column('runAt').timestamp(),
column('completedAt').timestamp(),
column('errorCode').varchar(),
column('errorDetails').json,
column('metrics').json,
)
.withIndexOn('testRunId')
.withForeignKey('testRunId', {
tableName: 'test_run',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('pastExecutionId', {
tableName: 'execution_entity',
columnName: 'id',
onDelete: 'SET NULL',
})
.withForeignKey('executionId', {
tableName: 'execution_entity',
columnName: 'id',
onDelete: 'SET NULL',
})
.withForeignKey('evaluationExecutionId', {
tableName: 'execution_entity',
columnName: 'id',
onDelete: 'SET NULL',
}).withTimestamps;
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(testCaseExecutionTableName);
}
}
@@ -0,0 +1,24 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
// We have to use raw query migration instead of schemaBuilder helpers,
// because the typeorm schema builder implements addColumns by a table recreate for sqlite
// which causes weird issues with the migration
export class AddErrorColumnsToTestRuns1737715421462 implements ReversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('test_run');
const errorCodeColumnName = escape.columnName('errorCode');
const errorDetailsColumnName = escape.columnName('errorDetails');
await runQuery(`ALTER TABLE ${tableName} ADD COLUMN ${errorCodeColumnName} VARCHAR(255);`);
await runQuery(`ALTER TABLE ${tableName} ADD COLUMN ${errorDetailsColumnName} TEXT;`);
}
async down({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('test_run');
const errorCodeColumnName = escape.columnName('errorCode');
const errorDetailsColumnName = escape.columnName('errorDetails');
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${errorCodeColumnName};`);
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${errorDetailsColumnName};`);
}
}
@@ -0,0 +1,60 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class CreateFolderTable1738709609940 implements ReversibleMigration {
async up({ runQuery, escape, schemaBuilder: { createTable, column } }: MigrationContext) {
const workflowTable = escape.tableName('workflow_entity');
const workflowFolderId = escape.columnName('parentFolderId');
const folderTable = escape.tableName('folder');
const folderId = escape.columnName('id');
await createTable('folder')
.withColumns(
column('id').varchar(36).primary.notNull,
column('name').varchar(128).notNull,
column('parentFolderId').varchar(36).default(null),
column('projectId').varchar(36).notNull,
)
.withForeignKey('projectId', {
tableName: 'project',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('parentFolderId', {
tableName: 'folder',
columnName: 'id',
onDelete: 'CASCADE',
})
.withIndexOn(['projectId', 'id'], true).withTimestamps;
await createTable('folder_tag')
.withColumns(
column('folderId').varchar(36).primary.notNull,
column('tagId').varchar(36).primary.notNull,
)
.withForeignKey('folderId', {
tableName: 'folder',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('tagId', {
tableName: 'tag_entity',
columnName: 'id',
onDelete: 'CASCADE',
});
await runQuery(
`ALTER TABLE ${workflowTable} ADD COLUMN ${workflowFolderId} VARCHAR(36) DEFAULT NULL REFERENCES ${folderTable}(${folderId}) ON DELETE SET NULL`,
);
}
async down({ runQuery, escape, schemaBuilder: { dropTable } }: MigrationContext) {
const workflowTable = escape.tableName('workflow_entity');
const workflowFolderId = escape.columnName('parentFolderId');
await runQuery(`ALTER TABLE ${workflowTable} DROP COLUMN ${workflowFolderId}`);
await dropTable('folder_tag');
await dropTable('folder');
}
}
@@ -0,0 +1,106 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const names = {
// table names
t: {
analyticsMetadata: 'analytics_metadata',
analyticsRaw: 'analytics_raw',
analyticsByPeriod: 'analytics_by_period',
workflowEntity: 'workflow_entity',
project: 'project',
},
// column names by table
c: {
analyticsMetadata: {
metaId: 'metaId',
projectId: 'projectId',
workflowId: 'workflowId',
},
analyticsRaw: {
metaId: 'metaId',
},
analyticsByPeriod: {
metaId: 'metaId',
type: 'type',
periodUnit: 'periodUnit',
periodStart: 'periodStart',
},
project: {
id: 'id',
},
workflowEntity: {
id: 'id',
},
},
};
export class CreateAnalyticsTables1739549398681 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(names.t.analyticsMetadata)
.withColumns(
column(names.c.analyticsMetadata.metaId).int.primary.autoGenerate2,
column(names.c.analyticsMetadata.workflowId).varchar(16),
column(names.c.analyticsMetadata.projectId).varchar(36),
column('workflowName').varchar(128).notNull,
column('projectName').varchar(255).notNull,
)
.withForeignKey(names.c.analyticsMetadata.workflowId, {
tableName: names.t.workflowEntity,
columnName: names.c.workflowEntity.id,
onDelete: 'SET NULL',
})
.withForeignKey(names.c.analyticsMetadata.projectId, {
tableName: names.t.project,
columnName: names.c.project.id,
onDelete: 'SET NULL',
});
const typeComment = '0: time_saved_minutes, 1: runtime_milliseconds, 2: success, 3: failure';
await createTable(names.t.analyticsRaw)
.withColumns(
column('id').int.primary.autoGenerate2,
column(names.c.analyticsRaw.metaId).int.notNull,
column('type').int.notNull.comment(typeComment),
column('value').int.notNull,
column('timestamp').timestampNoTimezone(0).default('CURRENT_TIMESTAMP').notNull,
)
.withForeignKey(names.c.analyticsRaw.metaId, {
tableName: names.t.analyticsMetadata,
columnName: names.c.analyticsMetadata.metaId,
onDelete: 'CASCADE',
});
await createTable(names.t.analyticsByPeriod)
.withColumns(
column('id').int.primary.autoGenerate2,
column(names.c.analyticsByPeriod.metaId).int.notNull,
column(names.c.analyticsByPeriod.type).int.notNull.comment(typeComment),
column('value').int.notNull,
column(names.c.analyticsByPeriod.periodUnit).int.notNull.comment(
'0: hour, 1: day, 2: week',
),
column(names.c.analyticsByPeriod.periodStart).timestampNoTimezone(0),
)
.withForeignKey(names.c.analyticsByPeriod.metaId, {
tableName: names.t.analyticsMetadata,
columnName: names.c.analyticsMetadata.metaId,
onDelete: 'CASCADE',
})
.withIndexOn(
[
names.c.analyticsByPeriod.periodStart,
names.c.analyticsByPeriod.type,
names.c.analyticsByPeriod.periodUnit,
names.c.analyticsByPeriod.metaId,
],
true,
);
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(names.t.analyticsRaw);
await dropTable(names.t.analyticsByPeriod);
await dropTable(names.t.analyticsMetadata);
}
}
@@ -0,0 +1,112 @@
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
const names = {
// table names
t: {
analyticsMetadata: 'analytics_metadata',
analyticsRaw: 'analytics_raw',
analyticsByPeriod: 'analytics_by_period',
insightsMetadata: 'insights_metadata',
insightsRaw: 'insights_raw',
insightsByPeriod: 'insights_by_period',
workflowEntity: 'workflow_entity',
project: 'project',
},
// column names by table
c: {
insightsMetadata: {
metaId: 'metaId',
projectId: 'projectId',
workflowId: 'workflowId',
},
insightsRaw: {
metaId: 'metaId',
},
insightsByPeriod: {
metaId: 'metaId',
type: 'type',
periodUnit: 'periodUnit',
periodStart: 'periodStart',
},
project: {
id: 'id',
},
workflowEntity: {
id: 'id',
},
},
};
export class RenameAnalyticsToInsights1741167584277 implements IrreversibleMigration {
async up({ schemaBuilder: { createTable, column, dropTable } }: MigrationContext) {
// Until the insights feature is released we're dropping the tables instead
// of migrating them.
await dropTable(names.t.analyticsRaw);
await dropTable(names.t.analyticsByPeriod);
await dropTable(names.t.analyticsMetadata);
await createTable(names.t.insightsMetadata)
.withColumns(
column(names.c.insightsMetadata.metaId).int.primary.autoGenerate2,
column(names.c.insightsMetadata.workflowId).varchar(16),
column(names.c.insightsMetadata.projectId).varchar(36),
column('workflowName').varchar(128).notNull,
column('projectName').varchar(255).notNull,
)
.withForeignKey(names.c.insightsMetadata.workflowId, {
tableName: names.t.workflowEntity,
columnName: names.c.workflowEntity.id,
onDelete: 'SET NULL',
})
.withForeignKey(names.c.insightsMetadata.projectId, {
tableName: names.t.project,
columnName: names.c.project.id,
onDelete: 'SET NULL',
})
.withIndexOn(names.c.insightsMetadata.workflowId, true);
const typeComment = '0: time_saved_minutes, 1: runtime_milliseconds, 2: success, 3: failure';
await createTable(names.t.insightsRaw)
.withColumns(
column('id').int.primary.autoGenerate2,
column(names.c.insightsRaw.metaId).int.notNull,
column('type').int.notNull.comment(typeComment),
column('value').int.notNull,
column('timestamp').timestampTimezone(0).default('CURRENT_TIMESTAMP').notNull,
)
.withForeignKey(names.c.insightsRaw.metaId, {
tableName: names.t.insightsMetadata,
columnName: names.c.insightsMetadata.metaId,
onDelete: 'CASCADE',
});
await createTable(names.t.insightsByPeriod)
.withColumns(
column('id').int.primary.autoGenerate2,
column(names.c.insightsByPeriod.metaId).int.notNull,
column(names.c.insightsByPeriod.type).int.notNull.comment(typeComment),
column('value').int.notNull,
column(names.c.insightsByPeriod.periodUnit).int.notNull.comment('0: hour, 1: day, 2: week'),
column(names.c.insightsByPeriod.periodStart)
.default('CURRENT_TIMESTAMP')
.timestampTimezone(0),
)
.withForeignKey(names.c.insightsByPeriod.metaId, {
tableName: names.t.insightsMetadata,
columnName: names.c.insightsMetadata.metaId,
onDelete: 'CASCADE',
})
.withIndexOn(
[
names.c.insightsByPeriod.periodStart,
names.c.insightsByPeriod.type,
names.c.insightsByPeriod.periodUnit,
names.c.insightsByPeriod.metaId,
],
true,
);
}
}
@@ -0,0 +1,41 @@
import type { GlobalRole } from '@n8n/permissions';
import { getApiKeyScopesForRole } from '@n8n/permissions';
import { GLOBAL_ROLES } from '../../constants';
import { ApiKey } from '../../entities';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
type ApiKeyWithRole = { id: string; role: GlobalRole };
export class AddScopesColumnToApiKeys1742918400000 implements ReversibleMigration {
async up({
runQuery,
escape,
queryRunner,
schemaBuilder: { addColumns, column },
}: MigrationContext) {
await addColumns('user_api_keys', [column('scopes').json]);
const userApiKeysTable = escape.tableName('user_api_keys');
const userTable = escape.tableName('user');
const idColumn = escape.columnName('id');
const userIdColumn = escape.columnName('userId');
const roleColumn = escape.columnName('role');
const apiKeysWithRoles = await runQuery<ApiKeyWithRole[]>(
`SELECT ${userApiKeysTable}.${idColumn} AS id, ${userTable}.${roleColumn} AS role FROM ${userApiKeysTable} JOIN ${userTable} ON ${userTable}.${idColumn} = ${userApiKeysTable}.${userIdColumn}`,
);
for (const { id, role } of apiKeysWithRoles) {
const dbRole = GLOBAL_ROLES[role];
const scopes = getApiKeyScopesForRole({
role: dbRole,
});
await queryRunner.manager.update(ApiKey, { id }, { scopes });
}
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns('user_api_keys', ['scopes']);
}
}
@@ -0,0 +1,65 @@
import type { MigrationContext, IrreversibleMigration } from '../migration-types';
const testRunTableName = 'test_run';
const testCaseExecutionTableName = 'test_case_execution';
export class ClearEvaluation1745322634000 implements IrreversibleMigration {
async up({
schemaBuilder: { dropTable, column, createTable },
queryRunner,
tablePrefix,
isSqlite,
isPostgres,
}: MigrationContext) {
// Drop test_metric, test_definition
await dropTable(testCaseExecutionTableName);
await dropTable(testRunTableName);
await dropTable('test_metric');
if (isSqlite) {
await queryRunner.query(`DROP TABLE IF EXISTS ${tablePrefix}test_definition;`);
} else if (isPostgres) {
await queryRunner.query(`DROP TABLE IF EXISTS ${tablePrefix}test_definition CASCADE;`);
}
await createTable(testRunTableName)
.withColumns(
column('id').varchar(36).primary.notNull,
column('workflowId').varchar(36).notNull,
column('status').varchar().notNull,
column('errorCode').varchar(),
column('errorDetails').json,
column('runAt').timestamp(),
column('completedAt').timestamp(),
column('metrics').json,
)
.withIndexOn('workflowId')
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
await createTable(testCaseExecutionTableName)
.withColumns(
column('id').varchar(36).primary.notNull,
column('testRunId').varchar(36).notNull,
column('executionId').int, // Execution of the workflow under test. Might be null if execution was deleted after the test run
column('status').varchar().notNull,
column('runAt').timestamp(),
column('completedAt').timestamp(),
column('errorCode').varchar(),
column('errorDetails').json,
column('metrics').json,
)
.withIndexOn('testRunId')
.withForeignKey('testRunId', {
tableName: 'test_run',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('executionId', {
tableName: 'execution_entity',
columnName: 'id',
onDelete: 'SET NULL',
}).withTimestamps;
}
}
@@ -0,0 +1,22 @@
import type { ReversibleMigration, MigrationContext } from '../migration-types';
const columnName = 'rootCount';
const tableName = 'workflow_statistics';
export class AddWorkflowStatisticsRootCount1745587087521 implements ReversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const escapedTableName = escape.tableName(tableName);
const escapedColumnName = escape.columnName(columnName);
await runQuery(
`ALTER TABLE ${escapedTableName} ADD COLUMN ${escapedColumnName} INTEGER DEFAULT 0`,
);
}
async down({ escape, runQuery }: MigrationContext) {
const escapedTableName = escape.tableName(tableName);
const escapedColumnName = escape.columnName(columnName);
await runQuery(`ALTER TABLE ${escapedTableName} DROP COLUMN ${escapedColumnName}`);
}
}
@@ -0,0 +1,22 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const columnName = 'isArchived';
const tableName = 'workflow_entity';
export class AddWorkflowArchivedColumn1745934666076 implements ReversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const escapedTableName = escape.tableName(tableName);
const escapedColumnName = escape.columnName(columnName);
await runQuery(
`ALTER TABLE ${escapedTableName} ADD COLUMN ${escapedColumnName} BOOLEAN NOT NULL DEFAULT FALSE`,
);
}
async down({ escape, runQuery }: MigrationContext) {
const escapedTableName = escape.tableName(tableName);
const escapedColumnName = escape.columnName(columnName);
await runQuery(`ALTER TABLE ${escapedTableName} DROP COLUMN ${escapedColumnName}`);
}
}

Some files were not shown because too many files have changed in this diff Show More