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
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:
@@ -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',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
const baseConfig = require('../../../jest.config');
|
||||
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
transform: {
|
||||
...baseConfig.transform,
|
||||
'^.+\\.ts$': ['ts-jest', { isolatedModules: false }],
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}>;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
+16
@@ -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']);
|
||||
}
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
}
|
||||
+22
@@ -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');
|
||||
}
|
||||
}
|
||||
+102
@@ -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']);
|
||||
}
|
||||
}
|
||||
+51
@@ -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);
|
||||
}
|
||||
}
|
||||
+27
@@ -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');
|
||||
}
|
||||
}
|
||||
+23
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
+22
@@ -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}`);
|
||||
}
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
}
|
||||
+22
@@ -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
Reference in New Issue
Block a user