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

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,66 @@
import { wrapMigration } from '../migration-helpers';
import type { IrreversibleMigration, ReversibleMigration } from '../migration-types';
describe('migrationHelpers.wrapMigration', () => {
test('throws if passed a migration without up method', async () => {
//
// ARRANGE
//
class TestMigration {}
//
// ACT & ASSERT
//
expect(() => wrapMigration(TestMigration as never)).toThrow(
'Migration "TestMigration" is missing the method `up`.',
);
});
test('wraps up method', async () => {
//
// ARRANGE
//
class TestMigration implements IrreversibleMigration {
async up() {}
}
const originalUp = jest.fn();
TestMigration.prototype.up = originalUp;
//
// ACT
//
wrapMigration(TestMigration);
await new TestMigration().up();
//
// ASSERT
//
expect(TestMigration.prototype.up).not.toBe(originalUp);
expect(originalUp).toHaveBeenCalledTimes(1);
});
test('wraps down method', async () => {
//
// ARRANGE
//
class TestMigration implements ReversibleMigration {
async up() {}
async down() {}
}
const originalDown = jest.fn();
TestMigration.prototype.down = originalDown;
//
// ACT
//
wrapMigration(TestMigration);
await new TestMigration().down();
//
// ASSERT
//
expect(TestMigration.prototype.down).not.toBe(originalDown);
expect(originalDown).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,40 @@
import type { WorkflowEntity } from '../../entities';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class UniqueWorkflowNames1620821879465 implements ReversibleMigration {
protected indexSuffix = '943d8f922be094eb507cb9a7f9';
async up({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('workflow_entity');
const workflowNames: Array<Pick<WorkflowEntity, 'name'>> = await runQuery(
`SELECT name FROM ${tableName}`,
);
for (const { name } of workflowNames) {
const duplicates: Array<Pick<WorkflowEntity, 'id' | 'name'>> = await runQuery(
`SELECT id, name FROM ${tableName} WHERE name = :name ORDER BY createdAt ASC`,
{ name },
);
if (duplicates.length > 1) {
await Promise.all(
duplicates.map(async (workflow, index) => {
if (index === 0) return;
return await runQuery(`UPDATE ${tableName} SET name = :name WHERE id = :id`, {
name: `${workflow.name} ${index + 1}`,
id: workflow.id,
});
}),
);
}
}
const indexName = escape.indexName(this.indexSuffix);
await runQuery(`CREATE UNIQUE INDEX ${indexName} ON ${tableName} ("name")`);
}
async down({ escape, runQuery }: MigrationContext) {
const indexName = escape.indexName(this.indexSuffix);
await runQuery(`DROP INDEX ${indexName}`);
}
}
@@ -0,0 +1,253 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import type { IWorkflowBase } from 'n8n-workflow';
import type { CredentialsEntity, WorkflowEntity } from '../../entities';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
type Credential = Pick<CredentialsEntity, 'id' | 'name' | 'type'>;
type ExecutionWithData = { id: string; workflowData: string | IWorkflowBase };
type Workflow = Pick<WorkflowEntity, 'id'> & { nodes: string | WorkflowEntity['nodes'] };
// replacing the credentials in workflows and execution
// `nodeType: name` changes to `nodeType: { id, name }`
export class UpdateWorkflowCredentials1630330987096 implements ReversibleMigration {
async up({ dbType, escape, parseJson, runQuery, runInBatches }: MigrationContext) {
const credentialsTable = escape.tableName('credentials_entity');
const workflowsTable = escape.tableName('workflow_entity');
const executionsTable = escape.tableName('execution_entity');
const dataColumn = escape.columnName('workflowData');
const waitTillColumn = escape.columnName('waitTill');
const credentialsEntities: Credential[] = await runQuery(
`SELECT id, name, type FROM ${credentialsTable}`,
);
const workflowsQuery = `SELECT id, nodes FROM ${workflowsTable}`;
await runInBatches<Workflow>(workflowsQuery, async (workflows) => {
workflows.forEach(async (workflow) => {
let credentialsUpdated = false;
const nodes = parseJson(workflow.nodes);
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
const matchingCredentials = credentialsEntities.find(
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id ?? null, name };
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
await runQuery(`UPDATE ${workflowsTable} SET nodes = :nodes WHERE id = :id`, {
nodes: JSON.stringify(nodes),
id: workflow.id,
});
}
});
});
const finishedValue = dbType === 'postgresdb' ? 'FALSE' : '0';
const waitingExecutionsQuery = `
SELECT id, ${dataColumn}
FROM ${executionsTable}
WHERE ${waitTillColumn} IS NOT NULL AND finished = ${finishedValue}
`;
await runInBatches<ExecutionWithData>(waitingExecutionsQuery, async (waitingExecutions) => {
waitingExecutions.forEach(async (execution) => {
let credentialsUpdated = false;
const workflowData = parseJson(execution.workflowData);
workflowData.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
const matchingCredentials = credentialsEntities.find(
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id ?? null, name };
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
await runQuery(
`UPDATE ${executionsTable}
SET ${escape.columnName('workflowData')} = :data WHERE id = :id`,
{ data: JSON.stringify(workflowData), id: execution.id },
);
}
});
});
const retryableExecutions: ExecutionWithData[] = await runQuery(`
SELECT id, ${dataColumn}
FROM ${executionsTable}
WHERE ${waitTillColumn} IS NULL AND finished = ${finishedValue} AND mode != 'retry'
ORDER BY ${escape.columnName('startedAt')} DESC
LIMIT 200
`);
retryableExecutions.forEach(async (execution) => {
let credentialsUpdated = false;
const workflowData = parseJson(execution.workflowData);
workflowData.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
const matchingCredentials = credentialsEntities.find(
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id ?? null, name };
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
await runQuery(
`UPDATE ${executionsTable}
SET ${escape.columnName('workflowData')} = :data WHERE id = :id`,
{ data: JSON.stringify(workflowData), id: execution.id },
);
}
});
}
async down({ dbType, escape, parseJson, runQuery, runInBatches }: MigrationContext) {
const credentialsTable = escape.tableName('credentials_entity');
const workflowsTable = escape.tableName('workflow_entity');
const executionsTable = escape.tableName('execution_entity');
const dataColumn = escape.columnName('workflowData');
const waitTillColumn = escape.columnName('waitTill');
const credentialsEntities: Credential[] = await runQuery(
`SELECT id, name, type FROM ${credentialsTable}`,
);
const workflowsQuery = `SELECT id, nodes FROM ${workflowsTable}`;
await runInBatches<Workflow>(workflowsQuery, async (workflows) => {
workflows.forEach(async (workflow) => {
let credentialsUpdated = false;
const nodes = parseJson(workflow.nodes);
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
const matchingCredentials = credentialsEntities.find(
// double-equals because creds.id can be string or number
// eslint-disable-next-line eqeqeq
(credentials) => credentials.id == creds.id && credentials.type === type,
);
if (matchingCredentials) {
// @ts-ignore
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
}
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
await runQuery(`UPDATE ${workflowsTable} SET nodes = :nodes WHERE id = :id`, {
nodes: JSON.stringify(nodes),
id: workflow.id,
});
}
});
});
const finishedValue = dbType === 'postgresdb' ? 'FALSE' : '0';
const waitingExecutionsQuery = `
SELECT id, ${dataColumn}
FROM ${executionsTable}
WHERE ${waitTillColumn} IS NOT NULL AND finished = ${finishedValue}
`;
await runInBatches<ExecutionWithData>(waitingExecutionsQuery, async (waitingExecutions) => {
waitingExecutions.forEach(async (execution) => {
let credentialsUpdated = false;
const workflowData = parseJson(execution.workflowData);
workflowData.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
const matchingCredentials = credentialsEntities.find(
// double-equals because creds.id can be string or number
// eslint-disable-next-line eqeqeq
(credentials) => credentials.id == creds.id && credentials.type === type,
);
if (matchingCredentials) {
// @ts-ignore
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
}
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
await runQuery(
`UPDATE ${executionsTable}
SET ${escape.columnName('workflowData')} = :data WHERE id = :id`,
{ data: JSON.stringify(workflowData), id: execution.id },
);
}
});
});
const retryableExecutions: ExecutionWithData[] = await runQuery(`
SELECT id, ${dataColumn}
FROM ${executionsTable}
WHERE ${waitTillColumn} IS NULL AND finished = ${finishedValue} AND mode != 'retry'
ORDER BY ${escape.columnName('startedAt')} DESC
LIMIT 200
`);
retryableExecutions.forEach(async (execution) => {
let credentialsUpdated = false;
const workflowData = parseJson(execution.workflowData);
workflowData.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
const matchingCredentials = credentialsEntities.find(
// double-equals because creds.id can be string or number
// eslint-disable-next-line eqeqeq
(credentials) => credentials.id == creds.id && credentials.type === type,
);
if (matchingCredentials) {
// @ts-ignore
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
}
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
await runQuery(
`UPDATE ${executionsTable}
SET ${escape.columnName('workflowData')} = :data WHERE id = :id`,
{ data: JSON.stringify(workflowData), id: execution.id },
);
}
});
}
}
@@ -0,0 +1,43 @@
import type { INode } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import type { WorkflowEntity } from '../../entities';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
type Workflow = Pick<WorkflowEntity, 'id'> & { nodes: string | INode[] };
export class AddNodeIds1658930531669 implements ReversibleMigration {
async up({ escape, runQuery, runInBatches, parseJson }: MigrationContext) {
const tableName = escape.tableName('workflow_entity');
const workflowsQuery = `SELECT id, nodes FROM ${tableName}`;
await runInBatches<Workflow>(workflowsQuery, async (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = parseJson(workflow.nodes);
nodes.forEach((node: INode) => {
if (!node.id) {
node.id = uuid();
}
});
await runQuery(`UPDATE ${tableName} SET nodes = :nodes WHERE id = :id`, {
nodes: JSON.stringify(nodes),
id: workflow.id,
});
});
});
}
async down({ escape, runQuery, runInBatches, parseJson }: MigrationContext) {
const tableName = escape.tableName('workflow_entity');
const workflowsQuery = `SELECT id, nodes FROM ${tableName}`;
await runInBatches<Workflow>(workflowsQuery, async (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = parseJson(workflow.nodes).map(({ id, ...rest }) => rest);
await runQuery(`UPDATE ${tableName} SET nodes = :nodes WHERE id = :id`, {
nodes: JSON.stringify(nodes),
id: workflow.id,
});
});
});
}
}
@@ -0,0 +1,82 @@
import { isObjectLiteral } from '@n8n/backend-common';
import type { IDataObject, INodeExecutionData } from 'n8n-workflow';
import type { MigrationContext, IrreversibleMigration } from '../migration-types';
type OldPinnedData = { [nodeName: string]: IDataObject[] };
type NewPinnedData = { [nodeName: string]: INodeExecutionData[] };
type Workflow = { id: number; pinData: string | OldPinnedData };
function isJsonKeyObject(item: unknown): item is {
json: unknown;
[keys: string]: unknown;
} {
if (!isObjectLiteral(item)) return false;
return Object.keys(item).includes('json');
}
/**
* Convert TEXT-type `pinData` column in `workflow_entity` table from
* `{ [nodeName: string]: IDataObject[] }` to `{ [nodeName: string]: INodeExecutionData[] }`
*/
export class AddJsonKeyPinData1659888469333 implements IrreversibleMigration {
async up({ escape, runQuery, runInBatches }: MigrationContext) {
const tableName = escape.tableName('workflow_entity');
const columnName = escape.columnName('pinData');
const selectQuery = `SELECT id, ${columnName} FROM ${tableName} WHERE ${columnName} IS NOT NULL`;
await runInBatches<Workflow>(selectQuery, async (workflows) => {
await Promise.all(
this.makeUpdateParams(workflows).map(
async (workflow) =>
await runQuery(`UPDATE ${tableName} SET ${columnName} = :pinData WHERE id = :id;`, {
pinData: workflow.pinData,
id: workflow.id,
}),
),
);
});
}
private makeUpdateParams(fetchedWorkflows: Workflow[]) {
return fetchedWorkflows.reduce<Workflow[]>((updateParams, { id, pinData: rawPinData }) => {
let pinDataPerWorkflow: OldPinnedData | NewPinnedData;
if (typeof rawPinData === 'string') {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
pinDataPerWorkflow = JSON.parse(rawPinData);
} catch {
pinDataPerWorkflow = {};
}
} else {
pinDataPerWorkflow = rawPinData;
}
const newPinDataPerWorkflow = Object.keys(pinDataPerWorkflow).reduce<NewPinnedData>(
(newPinDataPerWorkflow, nodeName) => {
let pinDataPerNode = pinDataPerWorkflow[nodeName];
if (!Array.isArray(pinDataPerNode)) {
pinDataPerNode = [pinDataPerNode];
}
if (pinDataPerNode.every((item) => item.json)) return newPinDataPerWorkflow;
newPinDataPerWorkflow[nodeName] = pinDataPerNode.map((item) =>
isJsonKeyObject(item) ? item : { json: item },
);
return newPinDataPerWorkflow;
},
{},
);
if (Object.keys(newPinDataPerWorkflow).length > 0) {
updateParams.push({ id, pinData: JSON.stringify(newPinDataPerWorkflow) });
}
return updateParams;
}, []);
}
}
@@ -0,0 +1,28 @@
import { v4 as uuidv4 } from 'uuid';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
type Workflow = { id: number };
export class AddWorkflowVersionIdColumn1669739707124 implements ReversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('workflow_entity');
const columnName = escape.columnName('versionId');
await runQuery(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} CHAR(36)`);
const workflowIds: Workflow[] = await runQuery(`SELECT id FROM ${tableName}`);
for (const { id } of workflowIds) {
await runQuery(`UPDATE ${tableName} SET ${columnName} = :versionId WHERE id = :id`, {
versionId: uuidv4(),
id,
});
}
}
async down({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('workflow_entity');
const columnName = escape.columnName('versionId');
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`);
}
}
@@ -0,0 +1,62 @@
import { StatisticsNames } from '../../entities/types-db';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class RemoveWorkflowDataLoadedFlag1671726148419 implements ReversibleMigration {
async up({ escape, dbType, runQuery }: MigrationContext) {
const workflowTableName = escape.tableName('workflow_entity');
const statisticsTableName = escape.tableName('workflow_statistics');
const columnName = escape.columnName('dataLoaded');
// If any existing workflow has dataLoaded set to true, insert the relevant information to the statistics table
const workflowIds: Array<{ id: number; dataLoaded: boolean }> = await runQuery(
`SELECT id, ${columnName} FROM ${workflowTableName}`,
);
const now =
dbType === 'sqlite' ? "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')" : 'CURRENT_TIMESTAMP(3)';
await Promise.all(
workflowIds.map(
async ({ id, dataLoaded }) =>
await (dataLoaded &&
runQuery(
`INSERT INTO ${statisticsTableName}
(${escape.columnName('workflowId')}, name, count, ${escape.columnName('latestEvent')})
VALUES (:id, :name, 1, ${now})`,
{ id, name: StatisticsNames.dataLoaded },
)),
),
);
await runQuery(`ALTER TABLE ${workflowTableName} DROP COLUMN ${columnName}`);
}
async down({ escape, runQuery }: MigrationContext) {
const workflowTableName = escape.tableName('workflow_entity');
const statisticsTableName = escape.tableName('workflow_statistics');
const columnName = escape.columnName('dataLoaded');
await runQuery(
`ALTER TABLE ${workflowTableName} ADD COLUMN ${columnName} BOOLEAN DEFAULT false`,
);
// Search through statistics for any workflows that have the dataLoaded stat
const workflowsIds: Array<{ workflowId: string }> = await runQuery(
`SELECT ${escape.columnName('workflowId')} FROM ${statisticsTableName} WHERE name = :name`,
{ name: StatisticsNames.dataLoaded },
);
await Promise.all(
workflowsIds.map(
async ({ workflowId }) =>
await runQuery(`UPDATE ${workflowTableName} SET ${columnName} = true WHERE id = :id`, {
id: workflowId,
}),
),
);
await runQuery(`DELETE FROM ${statisticsTableName} WHERE name = :name`, {
name: StatisticsNames.dataLoaded,
});
}
}
@@ -0,0 +1,68 @@
import { LDAP_FEATURE_NAME, LDAP_DEFAULT_CONFIGURATION } from '@n8n/constants';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class CreateLdapEntities1674509946020 implements ReversibleMigration {
async up({ escape, dbType, runQuery }: MigrationContext) {
const userTable = escape.tableName('user');
await runQuery(`ALTER TABLE ${userTable} ADD COLUMN disabled BOOLEAN NOT NULL DEFAULT false;`);
await runQuery(`
INSERT INTO ${escape.tableName('settings')}
(${escape.columnName('key')}, value, ${escape.columnName('loadOnStartup')})
VALUES ('${LDAP_FEATURE_NAME}', '${JSON.stringify(LDAP_DEFAULT_CONFIGURATION)}', true)
`);
const uuidColumnType = dbType === 'postgresdb' ? 'UUID' : 'VARCHAR(36)';
await runQuery(
`CREATE TABLE IF NOT EXISTS ${escape.tableName('auth_identity')} (
${escape.columnName('userId')} ${uuidColumnType} REFERENCES ${userTable} (id),
${escape.columnName('providerId')} VARCHAR(64) NOT NULL,
${escape.columnName('providerType')} VARCHAR(32) NOT NULL,
${escape.columnName('createdAt')} timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
${escape.columnName('updatedAt')} timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(${escape.columnName('providerId')}, ${escape.columnName('providerType')})
)`,
);
const idColumn =
dbType === 'sqlite'
? 'INTEGER PRIMARY KEY AUTOINCREMENT'
: dbType === 'postgresdb'
? 'SERIAL NOT NULL PRIMARY KEY'
: 'INTEGER NOT NULL AUTO_INCREMENT';
const timestampColumn =
dbType === 'sqlite'
? 'DATETIME NOT NULL'
: dbType === 'postgresdb'
? 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP'
: 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP';
await runQuery(
`CREATE TABLE IF NOT EXISTS ${escape.tableName('auth_provider_sync_history')} (
${escape.columnName('id')} ${idColumn},
${escape.columnName('providerType')} VARCHAR(32) NOT NULL,
${escape.columnName('runMode')} TEXT NOT NULL,
${escape.columnName('status')} TEXT NOT NULL,
${escape.columnName('startedAt')} ${timestampColumn},
${escape.columnName('endedAt')} ${timestampColumn},
${escape.columnName('scanned')} INTEGER NOT NULL,
${escape.columnName('created')} INTEGER NOT NULL,
${escape.columnName('updated')} INTEGER NOT NULL,
${escape.columnName('disabled')} INTEGER NOT NULL,
${escape.columnName('error')} TEXT
)`,
);
}
async down({ escape, runQuery }: MigrationContext) {
await runQuery(`DROP TABLE "${escape.tableName('auth_provider_sync_history')}`);
await runQuery(`DROP TABLE "${escape.tableName('auth_identity')}`);
await runQuery(`DELETE FROM ${escape.tableName('settings')} WHERE key = :key`, {
key: LDAP_FEATURE_NAME,
});
await runQuery(`ALTER TABLE ${escape.tableName('user')} DROP COLUMN disabled`);
}
}
@@ -0,0 +1,16 @@
import { UserError } from 'n8n-workflow';
import { WorkflowEntity } from '../../entities';
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
export class PurgeInvalidWorkflowConnections1675940580449 implements IrreversibleMigration {
async up({ queryRunner }: MigrationContext) {
const workflowCount = await queryRunner.manager.count(WorkflowEntity);
if (workflowCount > 0) {
throw new UserError(
'Migration "PurgeInvalidWorkflowConnections1675940580449" is no longer supported. Please upgrade to n8n@1.0.0 first.',
);
}
}
}
@@ -0,0 +1,14 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class RemoveResetPasswordColumns1690000000030 implements ReversibleMigration {
async up({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns('user', ['resetPasswordToken', 'resetPasswordTokenExpiration']);
}
async down({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns('user', [
column('resetPasswordToken').varchar(),
column('resetPasswordTokenExpiration').int,
]);
}
}
@@ -0,0 +1,15 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddMfaColumns1690000000030 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns('user', [
column('mfaEnabled').bool.notNull.default(false),
column('mfaSecret').text,
column('mfaRecoveryCodes').text,
]);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns('user', ['mfaEnabled', 'mfaSecret', 'mfaRecoveryCodes']);
}
}
@@ -0,0 +1,11 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class CreateWorkflowNameIndex1691088862123 implements ReversibleMigration {
async up({ schemaBuilder: { createIndex } }: MigrationContext) {
await createIndex('workflow_entity', ['name']);
}
async down({ schemaBuilder: { dropIndex } }: MigrationContext) {
await dropIndex('workflow_entity', ['name']);
}
}
@@ -0,0 +1,26 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const tableName = 'workflow_history';
export class CreateWorkflowHistoryTable1692967111175 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(tableName)
.withColumns(
column('versionId').varchar(36).primary.notNull,
column('workflowId').varchar(36).notNull,
column('nodes').text.notNull,
column('connections').text.notNull,
column('authors').varchar(255).notNull,
)
.withTimestamps.withIndexOn('workflowId')
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE',
});
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(tableName);
}
}
@@ -0,0 +1,19 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
/**
* Add an indexed column `deletedAt` to track soft-deleted executions.
* Add an index on `stoppedAt`, used by executions pruning.
*/
export class ExecutionSoftDelete1693491613982 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column, createIndex } }: MigrationContext) {
await addColumns('execution_entity', [column('deletedAt').timestamp()]);
await createIndex('execution_entity', ['deletedAt']);
await createIndex('execution_entity', ['stoppedAt']);
}
async down({ schemaBuilder: { dropColumns, dropIndex } }: MigrationContext) {
await dropIndex('execution_entity', ['stoppedAt']);
await dropIndex('execution_entity', ['deletedAt']);
await dropColumns('execution_entity', ['deletedAt']);
}
}
@@ -0,0 +1,22 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class DisallowOrphanExecutions1693554410387 implements ReversibleMigration {
/**
* Ensure all executions point to a workflow.
*/
async up({ escape, schemaBuilder: { addNotNull }, runQuery }: MigrationContext) {
const executionEntity = escape.tableName('execution_entity');
const workflowId = escape.columnName('workflowId');
await runQuery(`DELETE FROM ${executionEntity} WHERE ${workflowId} IS NULL;`);
await addNotNull('execution_entity', 'workflowId');
}
/**
* Reversal excludes restoring deleted rows.
*/
async down({ schemaBuilder: { dropNotNull } }: MigrationContext) {
await dropNotNull('execution_entity', 'workflowId');
}
}
@@ -0,0 +1,11 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddWorkflowMetadata1695128658538 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns('workflow_entity', [column('meta').json]);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns('workflow_entity', ['meta']);
}
}
@@ -0,0 +1,15 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const tableName = 'workflow_history';
export class ModifyWorkflowHistoryNodesAndConnections1695829275184 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, dropColumns, column } }: MigrationContext) {
await dropColumns(tableName, ['nodes', 'connections']);
await addColumns(tableName, [column('nodes').json.notNull, column('connections').json.notNull]);
}
async down({ schemaBuilder: { dropColumns, addColumns, column } }: MigrationContext) {
await dropColumns(tableName, ['nodes', 'connections']);
await addColumns(tableName, [column('nodes').text.notNull, column('connections').text.notNull]);
}
}
@@ -0,0 +1,60 @@
import { UnexpectedError } from 'n8n-workflow';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddGlobalAdminRole1700571993961 implements ReversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('role');
await runQuery(`INSERT INTO ${tableName} (name, scope) VALUES (:name, :scope)`, {
name: 'admin',
scope: 'global',
});
}
async down({ escape, runQuery }: MigrationContext) {
const roleTableName = escape.tableName('role');
const userTableName = escape.tableName('user');
const adminRoleIdResult = await runQuery<Array<{ id: number }>>(
`SELECT id FROM ${roleTableName} WHERE name = :name AND scope = :scope`,
{
name: 'admin',
scope: 'global',
},
);
const memberRoleIdResult = await runQuery<Array<{ id: number }>>(
`SELECT id FROM ${roleTableName} WHERE name = :name AND scope = :scope`,
{
name: 'member',
scope: 'global',
},
);
const adminRoleId = adminRoleIdResult[0]?.id;
if (adminRoleId === undefined) {
// Couldn't find admin role. It's a bit odd but it means we don't
// have anything to do.
return;
}
const memberRoleId = memberRoleIdResult[0]?.id;
if (!memberRoleId) {
throw new UnexpectedError('Could not find global member role!');
}
await runQuery(
`UPDATE ${userTableName} SET globalRoleId = :memberRoleId WHERE globalRoleId = :adminRoleId`,
{
memberRoleId,
adminRoleId,
},
);
await runQuery(`DELETE FROM ${roleTableName} WHERE name = :name AND scope = :scope`, {
name: 'admin',
scope: 'global',
});
}
}
@@ -0,0 +1,131 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
type Table = 'user' | 'shared_workflow' | 'shared_credentials';
const idColumns: Record<Table, string> = {
user: 'id',
shared_credentials: 'credentialsId',
shared_workflow: 'workflowId',
};
const uidColumns: Record<Table, string> = {
user: 'id',
shared_credentials: 'userId',
shared_workflow: 'userId',
};
const roleScopes: Record<Table, string> = {
user: 'global',
shared_credentials: 'credential',
shared_workflow: 'workflow',
};
const foreignKeySuffixes: Record<Table, string> = {
user: 'f0609be844f9200ff4365b1bb3d',
shared_credentials: 'c68e056637562000b68f480815a',
shared_workflow: '3540da03964527aa24ae014b780',
};
export class DropRoleMapping1705429061930 implements ReversibleMigration {
async up(context: MigrationContext) {
await this.migrateUp('user', context);
await this.migrateUp('shared_workflow', context);
await this.migrateUp('shared_credentials', context);
}
async down(context: MigrationContext) {
await this.migrateDown('shared_workflow', context);
await this.migrateDown('shared_credentials', context);
await this.migrateDown('user', context);
}
private async migrateUp(
table: Table,
{
escape,
runQuery,
schemaBuilder: { addNotNull, addColumns, dropColumns, dropForeignKey, column },
tablePrefix,
}: MigrationContext,
) {
await addColumns(table, [column('role').text]);
const roleTable = escape.tableName('role');
const tableName = escape.tableName(table);
const idColumn = escape.columnName(idColumns[table]);
const uidColumn = escape.columnName(uidColumns[table]);
const roleColumnName = table === 'user' ? 'globalRoleId' : 'roleId';
const roleColumn = escape.columnName(roleColumnName);
const scope = roleScopes[table];
const roleField = `'${scope}:' || R.name`;
const subQuery = `
SELECT ${roleField} as role, T.${idColumn} as id${
table !== 'user' ? `, T.${uidColumn} as uid` : ''
}
FROM ${tableName} T
LEFT JOIN ${roleTable} R
ON T.${roleColumn} = R.id and R.scope = '${scope}'`;
const where = `WHERE ${tableName}.${idColumn} = mapping.id${
table !== 'user' ? ` AND ${tableName}.${uidColumn} = mapping.uid` : ''
}`;
const swQuery = `UPDATE ${tableName}
SET role = mapping.role
FROM (${subQuery}) as mapping
${where}`;
await runQuery(swQuery);
await addNotNull(table, 'role');
await dropForeignKey(
table,
roleColumnName,
['role', 'id'],
`FK_${tablePrefix}${foreignKeySuffixes[table]}`,
);
await dropColumns(table, [roleColumnName]);
}
private async migrateDown(
table: Table,
{
escape,
runQuery,
schemaBuilder: { addNotNull, addColumns, dropColumns, addForeignKey, column },
tablePrefix,
}: MigrationContext,
) {
const roleColumnName = table === 'user' ? 'globalRoleId' : 'roleId';
await addColumns(table, [column(roleColumnName).int]);
const roleTable = escape.tableName('role');
const tableName = escape.tableName(table);
const idColumn = escape.columnName(idColumns[table]);
const uidColumn = escape.columnName(uidColumns[table]);
const roleColumn = escape.columnName(roleColumnName);
const scope = roleScopes[table];
const roleField = `'${scope}:' || R.name`;
const subQuery = `
SELECT R.id as role_id, T.${idColumn} as id${table !== 'user' ? `, T.${uidColumn} as uid` : ''}
FROM ${tableName} T
LEFT JOIN ${roleTable} R
ON T.role = ${roleField} and R.scope = '${scope}'`;
const where = `WHERE ${tableName}.${idColumn} = mapping.id${
table !== 'user' ? ` AND ${tableName}.${uidColumn} = mapping.uid` : ''
}`;
const query = `UPDATE ${tableName}
SET ${roleColumn} = mapping.role_id
FROM (${subQuery}) as mapping
${where}`;
await runQuery(query);
await addNotNull(table, roleColumnName);
await addForeignKey(
table,
roleColumnName,
['role', 'id'],
`FK_${tablePrefix}${foreignKeySuffixes[table]}`,
);
await dropColumns(table, ['role']);
}
}
@@ -0,0 +1,9 @@
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
export class RemoveFailedExecutionStatus1711018413374 implements IrreversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const executionEntity = escape.tableName('execution_entity');
await runQuery(`UPDATE ${executionEntity} SET status = 'error' WHERE status = 'failed';`);
}
}
@@ -0,0 +1,121 @@
import { Container } from '@n8n/di';
import { Cipher, InstanceSettings } from 'n8n-core';
import { jsonParse } from 'n8n-workflow';
import { readFile, writeFile, rm } from 'node:fs/promises';
import path from 'node:path';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
/**
* Move SSH key pair from file system to database, to enable SSH connections
* when running n8n in multiple containers - mains, webhooks, workers, etc.
*/
export class MoveSshKeysToDatabase1711390882123 implements ReversibleMigration {
private readonly settingsKey = 'features.sourceControl.sshKeys';
private readonly privateKeyPath = path.join(
Container.get(InstanceSettings).n8nFolder,
'ssh',
'key',
);
private readonly publicKeyPath = this.privateKeyPath + '.pub';
private readonly cipher = Container.get(Cipher);
async up({ escape, runQuery, logger, migrationName }: MigrationContext) {
let privateKey, publicKey;
try {
[privateKey, publicKey] = await Promise.all([
readFile(this.privateKeyPath, { encoding: 'utf8' }),
readFile(this.publicKeyPath, { encoding: 'utf8' }),
]);
} catch {
logger.info(`[${migrationName}] No SSH keys in filesystem, skipping`);
return;
}
if (!privateKey && !publicKey) {
logger.info(`[${migrationName}] No SSH keys in filesystem, skipping`);
return;
}
const settings = escape.tableName('settings');
const key = escape.columnName('key');
const value = escape.columnName('value');
const rows: Array<{ value: string }> = await runQuery(
`SELECT value FROM ${settings} WHERE ${key} = '${this.settingsKey}';`,
);
if (rows.length === 1) {
logger.info(`[${migrationName}] SSH keys already in database, skipping`);
return;
}
if (!privateKey) {
logger.error(`[${migrationName}] No private key found, skipping`);
return;
}
const settingsValue = JSON.stringify({
encryptedPrivateKey: this.cipher.encrypt(privateKey),
publicKey,
});
await runQuery(
`INSERT INTO ${settings} (${key}, ${value}) VALUES ('${this.settingsKey}', '${settingsValue}');`,
);
try {
await Promise.all([rm(this.privateKeyPath), rm(this.publicKeyPath)]);
} catch (e) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
const error = e instanceof Error ? e : new Error(`${e}`);
logger.error(
`[${migrationName}] Failed to remove SSH keys from filesystem: ${error.message}`,
);
}
}
async down({ escape, runQuery, logger, migrationName }: MigrationContext) {
const settings = escape.tableName('settings');
const key = escape.columnName('key');
const rows: Array<{ value: string }> = await runQuery(
`SELECT value FROM ${settings} WHERE ${key} = '${this.settingsKey}';`,
);
if (rows.length !== 1) {
logger.info(`[${migrationName}] No SSH keys in database, skipping revert`);
return;
}
const [row] = rows;
type KeyPair = { publicKey: string; encryptedPrivateKey: string };
const dbKeyPair = jsonParse<KeyPair | null>(row.value, { fallbackValue: null });
if (!dbKeyPair) {
logger.info(`[${migrationName}] Malformed SSH keys in database, skipping revert`);
return;
}
const privateKey = this.cipher.decrypt(dbKeyPair.encryptedPrivateKey);
const { publicKey } = dbKeyPair;
try {
await Promise.all([
writeFile(this.privateKeyPath, privateKey, { encoding: 'utf8', mode: 0o600 }),
writeFile(this.publicKeyPath, publicKey, { encoding: 'utf8', mode: 0o600 }),
]);
} catch {
logger.error(`[${migrationName}] Failed to write SSH keys to filesystem, skipping revert`);
return;
}
await runQuery(`DELETE FROM ${settings} WHERE ${key} = 'features.sourceControl.sshKeys';`);
}
}
@@ -0,0 +1,7 @@
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
export class RemoveNodesAccess1712044305787 implements IrreversibleMigration {
async up({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns('credentials_entity', ['nodesAccess']);
}
}
@@ -0,0 +1,315 @@
import type { ProjectRole } from '@n8n/permissions';
import { generateNanoId } from '@n8n/utils';
import { UserError } from 'n8n-workflow';
import type { User } from '../../entities';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const projectAdminRole: ProjectRole = 'project:personalOwner';
type RelationTable = 'shared_workflow' | 'shared_credentials';
const table = {
sharedCredentials: 'shared_credentials',
sharedCredentialsTemp: 'shared_credentials_2',
sharedWorkflow: 'shared_workflow',
sharedWorkflowTemp: 'shared_workflow_2',
project: 'project',
user: 'user',
projectRelation: 'project_relation',
} as const;
function escapeNames(escape: MigrationContext['escape']) {
const t = {
project: escape.tableName(table.project),
projectRelation: escape.tableName(table.projectRelation),
sharedCredentials: escape.tableName(table.sharedCredentials),
sharedCredentialsTemp: escape.tableName(table.sharedCredentialsTemp),
sharedWorkflow: escape.tableName(table.sharedWorkflow),
sharedWorkflowTemp: escape.tableName(table.sharedWorkflowTemp),
user: escape.tableName(table.user),
};
const c = {
createdAt: escape.columnName('createdAt'),
updatedAt: escape.columnName('updatedAt'),
workflowId: escape.columnName('workflowId'),
credentialsId: escape.columnName('credentialsId'),
userId: escape.columnName('userId'),
projectId: escape.columnName('projectId'),
firstName: escape.columnName('firstName'),
lastName: escape.columnName('lastName'),
};
return { t, c };
}
export class CreateProject1714133768519 implements ReversibleMigration {
async setupTables({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(table.project).withColumns(
column('id').varchar(36).primary.notNull,
column('name').varchar(255).notNull,
column('type').varchar(36).notNull,
).withTimestamps;
await createTable(table.projectRelation)
.withColumns(
column('projectId').varchar(36).primary.notNull,
column('userId').uuid.primary.notNull,
column('role').varchar().notNull,
)
.withIndexOn('projectId')
.withIndexOn('userId')
.withForeignKey('projectId', {
tableName: table.project,
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
}
async alterSharedTable(
relationTableName: RelationTable,
{
escape,
runQuery,
schemaBuilder: { addForeignKey, addColumns, addNotNull, createIndex, column },
}: MigrationContext,
) {
const projectIdColumn = column('projectId').varchar(36).default('NULL');
await addColumns(relationTableName, [projectIdColumn]);
const relationTable = escape.tableName(relationTableName);
const { t, c } = escapeNames(escape);
// Populate projectId
const subQuery = `
SELECT P.id as ${c.projectId}, T.${c.userId}
FROM ${t.projectRelation} T
LEFT JOIN ${t.project} P
ON T.${c.projectId} = P.id AND P.type = 'personal'
LEFT JOIN ${relationTable} S
ON T.${c.userId} = S.${c.userId}
WHERE P.id IS NOT NULL
`;
const swQuery = `UPDATE ${relationTable}
SET ${c.projectId} = mapping.${c.projectId}
FROM (${subQuery}) as mapping
WHERE ${relationTable}.${c.userId} = mapping.${c.userId}`;
await runQuery(swQuery);
await addForeignKey(relationTableName, 'projectId', ['project', 'id']);
await addNotNull(relationTableName, 'projectId');
// Index the new projectId column
await createIndex(relationTableName, ['projectId']);
}
async alterSharedCredentials({
escape,
runQuery,
schemaBuilder: { column, createTable, dropTable },
}: MigrationContext) {
await createTable(table.sharedCredentialsTemp)
.withColumns(
column('credentialsId').varchar(36).notNull.primary,
column('projectId').varchar(36).notNull.primary,
column('role').text.notNull,
)
.withForeignKey('credentialsId', {
tableName: 'credentials_entity',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('projectId', {
tableName: table.project,
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
const { c, t } = escapeNames(escape);
await runQuery(`
INSERT INTO ${t.sharedCredentialsTemp} (${c.createdAt}, ${c.updatedAt}, ${c.credentialsId}, ${c.projectId}, role)
SELECT ${c.createdAt}, ${c.updatedAt}, ${c.credentialsId}, ${c.projectId}, role FROM ${t.sharedCredentials};
`);
await dropTable(table.sharedCredentials);
await runQuery(`ALTER TABLE ${t.sharedCredentialsTemp} RENAME TO ${t.sharedCredentials};`);
}
async alterSharedWorkflow({
escape,
runQuery,
schemaBuilder: { column, createTable, dropTable },
}: MigrationContext) {
await createTable(table.sharedWorkflowTemp)
.withColumns(
column('workflowId').varchar(36).notNull.primary,
column('projectId').varchar(36).notNull.primary,
column('role').text.notNull,
)
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('projectId', {
tableName: table.project,
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
const { c, t } = escapeNames(escape);
await runQuery(`
INSERT INTO ${t.sharedWorkflowTemp} (${c.createdAt}, ${c.updatedAt}, ${c.workflowId}, ${c.projectId}, role)
SELECT ${c.createdAt}, ${c.updatedAt}, ${c.workflowId}, ${c.projectId}, role FROM ${t.sharedWorkflow};
`);
await dropTable(table.sharedWorkflow);
await runQuery(`ALTER TABLE ${t.sharedWorkflowTemp} RENAME TO ${t.sharedWorkflow};`);
}
async createUserPersonalProjects({ runQuery, runInBatches, escape }: MigrationContext) {
const { c, t } = escapeNames(escape);
const getUserQuery = `SELECT id, ${c.firstName}, ${c.lastName}, email FROM ${t.user}`;
await runInBatches<Pick<User, 'id' | 'firstName' | 'lastName' | 'email'>>(
getUserQuery,
async (users) => {
await Promise.all(
users.map(async (user) => {
const projectId = generateNanoId();
const name = this.createPersonalProjectName(user.firstName, user.lastName, user.email);
await runQuery(
`INSERT INTO ${t.project} (id, type, name) VALUES (:projectId, 'personal', :name)`,
{
projectId,
name,
},
);
await runQuery(
`INSERT INTO ${t.projectRelation} (${c.projectId}, ${c.userId}, role) VALUES (:projectId, :userId, :projectRole)`,
{
projectId,
userId: user.id,
projectRole: projectAdminRole,
},
);
}),
);
},
);
}
// Duplicated from packages/@n8n/db/src/entities/User.ts
// Reason:
// This migration should work the same even if we refactor the function in
// `User.ts`.
createPersonalProjectName(firstName?: string, lastName?: string, email?: string) {
if (firstName && lastName && email) {
return `${firstName} ${lastName} <${email}>`;
} else if (email) {
return `<${email}>`;
} else {
return 'Unnamed Project';
}
}
async up(context: MigrationContext) {
await this.setupTables(context);
await this.createUserPersonalProjects(context);
await this.alterSharedTable(table.sharedCredentials, context);
await this.alterSharedCredentials(context);
await this.alterSharedTable(table.sharedWorkflow, context);
await this.alterSharedWorkflow(context);
}
async down({ logger, escape, runQuery, schemaBuilder: sb }: MigrationContext) {
const { t, c } = escapeNames(escape);
// 0. check if all projects are personal projects
const [{ count: nonPersonalProjects }] = await runQuery<[{ count: number }]>(
`SELECT COUNT(*) FROM ${t.project} WHERE type <> 'personal';`,
);
if (nonPersonalProjects > 0) {
const message =
'Down migration only possible when there are no projects. Please delete all projects that were created via the UI first.';
logger.error(message);
throw new UserError(message);
}
// 1. create temp table for shared workflows
await sb
.createTable(table.sharedWorkflowTemp)
.withColumns(
sb.column('workflowId').varchar(36).notNull.primary,
sb.column('userId').uuid.notNull.primary,
sb.column('role').text.notNull,
)
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE',
name: undefined,
})
.withForeignKey('userId', {
tableName: table.user,
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
// 2. migrate data into temp table
await runQuery(`
INSERT INTO ${t.sharedWorkflowTemp} (${c.createdAt}, ${c.updatedAt}, ${c.workflowId}, role, ${c.userId})
SELECT SW.${c.createdAt}, SW.${c.updatedAt}, SW.${c.workflowId}, SW.role, PR.${c.userId}
FROM ${t.sharedWorkflow} SW
LEFT JOIN project_relation PR on SW.${c.projectId} = PR.${c.projectId} AND PR.role = 'project:personalOwner'
`);
// 3. drop shared workflow table
await sb.dropTable(table.sharedWorkflow);
// 4. rename temp table
await runQuery(`ALTER TABLE ${t.sharedWorkflowTemp} RENAME TO ${t.sharedWorkflow};`);
// 5. same for shared creds
await sb
.createTable(table.sharedCredentialsTemp)
.withColumns(
sb.column('credentialsId').varchar(36).notNull.primary,
sb.column('userId').uuid.notNull.primary,
sb.column('role').text.notNull,
)
.withForeignKey('credentialsId', {
tableName: 'credentials_entity',
columnName: 'id',
onDelete: 'CASCADE',
name: undefined,
})
.withForeignKey('userId', {
tableName: table.user,
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
await runQuery(`
INSERT INTO ${t.sharedCredentialsTemp} (${c.createdAt}, ${c.updatedAt}, ${c.credentialsId}, role, ${c.userId})
SELECT SC.${c.createdAt}, SC.${c.updatedAt}, SC.${c.credentialsId}, SC.role, PR.${c.userId}
FROM ${t.sharedCredentials} SC
LEFT JOIN project_relation PR on SC.${c.projectId} = PR.${c.projectId} AND PR.role = 'project:personalOwner'
`);
await sb.dropTable(table.sharedCredentials);
await runQuery(`ALTER TABLE ${t.sharedCredentialsTemp} RENAME TO ${t.sharedCredentials};`);
// 6. drop project and project relation table
await sb.dropTable(table.projectRelation);
await sb.dropTable(table.project);
}
}
@@ -0,0 +1,22 @@
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
export class MakeExecutionStatusNonNullable1714133768521 implements IrreversibleMigration {
async up({ escape, runQuery, schemaBuilder }: MigrationContext) {
const executionEntity = escape.tableName('execution_entity');
const status = escape.columnName('status');
const finished = escape.columnName('finished');
const query = `
UPDATE ${executionEntity}
SET ${status} = CASE
WHEN ${finished} = true THEN 'success'
WHEN ${finished} = false THEN 'error'
END
WHERE ${status} IS NULL;
`;
await runQuery(query);
await schemaBuilder.addNotNull('execution_entity', 'status');
}
}
@@ -0,0 +1,102 @@
import { nanoid } from 'nanoid';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddConstraintToExecutionMetadata1720101653148 implements ReversibleMigration {
async up(context: MigrationContext) {
const { createTable, dropTable, column } = context.schemaBuilder;
const { escape } = context;
const executionMetadataTableRaw = 'execution_metadata';
const executionMetadataTable = escape.tableName(executionMetadataTableRaw);
const executionMetadataTableTempRaw = 'execution_metadata_temp';
const executionMetadataTableTemp = escape.tableName(executionMetadataTableTempRaw);
const id = escape.columnName('id');
const executionId = escape.columnName('executionId');
const key = escape.columnName('key');
const value = escape.columnName('value');
await createTable(executionMetadataTableTempRaw)
.withColumns(
column('id').int.notNull.primary.autoGenerate,
column('executionId').int.notNull,
column('key').varchar(255).notNull,
column('value').text.notNull,
)
.withForeignKey('executionId', {
tableName: 'execution_entity',
columnName: 'id',
onDelete: 'CASCADE',
name: undefined,
})
.withIndexOn(['executionId', 'key'], true);
await context.runQuery(`
INSERT INTO ${executionMetadataTableTemp} (${id}, ${executionId}, ${key}, ${value})
SELECT MAX(${id}) as ${id}, ${executionId}, ${key}, MAX(${value})
FROM ${executionMetadataTable}
GROUP BY ${executionId}, ${key}
ON CONFLICT (${executionId}, ${key}) DO UPDATE SET
id = EXCLUDED.id,
value = EXCLUDED.value
WHERE EXCLUDED.id > ${executionMetadataTableTemp}.id;
`);
await dropTable(executionMetadataTableRaw);
await context.runQuery(
`ALTER TABLE ${executionMetadataTableTemp} RENAME TO ${executionMetadataTable};`,
);
}
async down(context: MigrationContext) {
const { createTable, dropTable, column } = context.schemaBuilder;
const { escape } = context;
const executionMetadataTableRaw = 'execution_metadata';
const executionMetadataTable = escape.tableName(executionMetadataTableRaw);
const executionMetadataTableTempRaw = 'execution_metadata_temp';
const executionMetadataTableTemp = escape.tableName(executionMetadataTableTempRaw);
const id = escape.columnName('id');
const executionId = escape.columnName('executionId');
const key = escape.columnName('key');
const value = escape.columnName('value');
await createTable(executionMetadataTableTempRaw)
.withColumns(
// INFO: The PK names that TypeORM creates are predictable and thus it
// will create a PK name which already exists in the current
// execution_metadata table. That's why we have to randomize the PK name
// here.
column('id').int.notNull.primaryWithName(nanoid()).autoGenerate,
column('executionId').int.notNull,
column('key').text.notNull,
column('value').text.notNull,
)
.withForeignKey('executionId', {
tableName: 'execution_entity',
columnName: 'id',
onDelete: 'CASCADE',
name: undefined,
});
await context.runQuery(`
INSERT INTO ${executionMetadataTableTemp} (${id}, ${executionId}, ${key}, ${value})
SELECT ${id}, ${executionId}, ${key}, ${value} FROM ${executionMetadataTable};
`);
await dropTable(executionMetadataTableRaw);
await context.runQuery(
`ALTER TABLE ${executionMetadataTableTemp} RENAME TO ${executionMetadataTable};`,
);
if (context.dbType === 'postgresdb') {
// Update sequence so that inserts continue with the next highest id.
const tableName = escape.tableName('execution_metadata');
const sequenceName = escape.tableName('execution_metadata_temp_id_seq1');
await context.runQuery(
`SELECT setval('${sequenceName}', (SELECT MAX(id) FROM ${tableName}));`,
);
}
}
}
@@ -0,0 +1,16 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const tableName = 'invalid_auth_token';
export class CreateInvalidAuthTokenTable1723627610222 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(tableName).withColumns(
column('token').varchar(512).primary,
column('expiresAt').timestamp().notNull,
);
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(tableName);
}
}
@@ -0,0 +1,102 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
/**
* Add new indices:
*
* - `workflowId, startedAt` for `ExecutionRepository.findManyByRangeQuery` (default query) and for `ExecutionRepository.findManyByRangeQuery` (filter query)
* - `waitTill, status, deletedAt` for `ExecutionRepository.getWaitingExecutions`
* - `stoppedAt, status, deletedAt` for `ExecutionRepository.softDeletePrunableExecutions`
*
* Remove unused indices in sqlite:
*
* - `stoppedAt` (duplicate with different casing)
* - `waitTill`
* - `status, workflowId`
*
* Remove unused indices in all DBs:
*
* - `waitTill, id`
* - `workflowId, id`
*
* Remove incomplete index in all DBs:
*
* - `stopped_at` (replaced with composite index)
*
* Keep index as is:
*
* - `deletedAt` for query at `ExecutionRepository.hardDeleteSoftDeletedExecutions`
*/
export class RefactorExecutionIndices1723796243146 implements ReversibleMigration {
async up({ schemaBuilder, isPostgres, isSqlite, runQuery, escape }: MigrationContext) {
if (isSqlite || isPostgres) {
const executionEntity = escape.tableName('execution_entity');
const workflowId = escape.columnName('workflowId');
const startedAt = escape.columnName('startedAt');
const waitTill = escape.columnName('waitTill');
const status = escape.columnName('status');
const deletedAt = escape.columnName('deletedAt');
const stoppedAt = escape.columnName('stoppedAt');
await runQuery(`
CREATE INDEX idx_execution_entity_workflow_id_started_at
ON ${executionEntity} (${workflowId}, ${startedAt})
WHERE ${startedAt} IS NOT NULL AND ${deletedAt} IS NULL;
`);
await runQuery(`
CREATE INDEX idx_execution_entity_wait_till_status_deleted_at
ON ${executionEntity} (${waitTill}, ${status}, ${deletedAt})
WHERE ${waitTill} IS NOT NULL AND ${deletedAt} IS NULL;
`);
await runQuery(`
CREATE INDEX idx_execution_entity_stopped_at_status_deleted_at
ON ${executionEntity} (${stoppedAt}, ${status}, ${deletedAt})
WHERE ${stoppedAt} IS NOT NULL AND ${deletedAt} IS NULL;
`);
}
if (isSqlite) {
await schemaBuilder.dropIndex('execution_entity', ['waitTill'], {
customIndexName: 'idx_execution_entity_wait_till',
skipIfMissing: true,
});
await schemaBuilder.dropIndex('execution_entity', ['status', 'workflowId'], {
customIndexName: 'IDX_8b6f3f9ae234f137d707b98f3bf43584',
skipIfMissing: true,
});
}
// all DBs
await schemaBuilder.dropIndex(
'execution_entity',
['stoppedAt'],
isSqlite ? { customIndexName: 'idx_execution_entity_stopped_at', skipIfMissing: true } : {},
);
await schemaBuilder.dropIndex('execution_entity', ['waitTill', 'id'], {
customIndexName: isPostgres
? 'IDX_85b981df7b444f905f8bf50747'
: 'IDX_b94b45ce2c73ce46c54f20b5f9',
skipIfMissing: true,
});
await schemaBuilder.dropIndex('execution_entity', ['workflowId', 'id'], {
customIndexName: isPostgres
? 'idx_execution_entity_workflow_id_id'
: 'IDX_81fc04c8a17de15835713505e4',
skipIfMissing: true,
});
}
async down({ schemaBuilder }: MigrationContext) {
await schemaBuilder.dropIndex('execution_entity', ['workflowId', 'startedAt']);
await schemaBuilder.dropIndex('execution_entity', ['waitTill', 'status']);
await schemaBuilder.dropIndex('execution_entity', ['stoppedAt', 'deletedAt', 'status']);
await schemaBuilder.createIndex('execution_entity', ['waitTill', 'id']);
await schemaBuilder.createIndex('execution_entity', ['stoppedAt']);
await schemaBuilder.createIndex('execution_entity', ['workflowId', 'id']);
}
}
@@ -0,0 +1,51 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const annotationsTableName = 'execution_annotations';
const annotationTagsTableName = 'annotation_tag_entity';
const annotationTagMappingsTableName = 'execution_annotation_tags';
export class CreateAnnotationTables1724753530828 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(annotationsTableName)
.withColumns(
column('id').int.notNull.primary.autoGenerate,
column('executionId').int.notNull,
column('vote').varchar(6),
column('note').text,
)
.withIndexOn('executionId', true)
.withForeignKey('executionId', {
tableName: 'execution_entity',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
await createTable(annotationTagsTableName)
.withColumns(column('id').varchar(16).primary.notNull, column('name').varchar(24).notNull)
.withIndexOn('name', true).withTimestamps;
await createTable(annotationTagMappingsTableName)
.withColumns(
column('annotationId').int.notNull.primary,
column('tagId').varchar(24).notNull.primary,
)
.withForeignKey('annotationId', {
tableName: annotationsTableName,
columnName: 'id',
onDelete: 'CASCADE',
})
.withIndexOn('tagId')
.withIndexOn('annotationId')
.withForeignKey('tagId', {
tableName: annotationTagsTableName,
columnName: 'id',
onDelete: 'CASCADE',
});
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(annotationTagMappingsTableName);
await dropTable(annotationTagsTableName);
await dropTable(annotationsTableName);
}
}
@@ -0,0 +1,100 @@
import { generateNanoId } from '@n8n/utils';
import type { ApiKey } from '../../entities';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddApiKeysTable1724951148974 implements ReversibleMigration {
async up({
queryRunner,
escape,
runQuery,
schemaBuilder: { createTable, column },
}: MigrationContext) {
const userTable = escape.tableName('user');
const userApiKeysTable = escape.tableName('user_api_keys');
const userIdColumn = escape.columnName('userId');
const apiKeyColumn = escape.columnName('apiKey');
const labelColumn = escape.columnName('label');
const idColumn = escape.columnName('id');
// Create the new table
await createTable('user_api_keys')
.withColumns(
column('id').varchar(36).primary,
column('userId').uuid.notNull,
column('label').varchar(100).notNull,
column('apiKey').varchar().notNull,
)
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'CASCADE',
})
.withIndexOn(['userId', 'label'], true)
.withIndexOn(['apiKey'], true).withTimestamps;
const usersWithApiKeys = (await queryRunner.query(
`SELECT ${idColumn}, ${apiKeyColumn} FROM ${userTable} WHERE ${apiKeyColumn} IS NOT NULL`,
)) as Array<Partial<ApiKey>>;
// Move the apiKey from the users table to the new table
await Promise.all(
usersWithApiKeys.map(
async (user: { id: string; apiKey: string }) =>
await runQuery(
`INSERT INTO ${userApiKeysTable} (${idColumn}, ${userIdColumn}, ${apiKeyColumn}, ${labelColumn}) VALUES (:id, :userId, :apiKey, :label)`,
{
id: generateNanoId(),
userId: user.id,
apiKey: user.apiKey,
label: 'My API Key',
},
),
),
);
// Drop apiKey column on user's table
await queryRunner.query(`ALTER TABLE ${userTable} DROP COLUMN ${apiKeyColumn};`);
}
async down({
queryRunner,
runQuery,
schemaBuilder: { dropTable, addColumns, createIndex, column },
escape,
}: MigrationContext) {
const userTable = escape.tableName('user');
const userApiKeysTable = escape.tableName('user_api_keys');
const apiKeyColumn = escape.columnName('apiKey');
const userIdColumn = escape.columnName('userId');
const idColumn = escape.columnName('id');
const createdAtColumn = escape.columnName('createdAt');
await addColumns('user', [column('apiKey').varchar()]);
await createIndex('user', ['apiKey'], true);
const queryToGetUsersApiKeys = `
SELECT DISTINCT ON
(${userIdColumn}) ${userIdColumn},
${apiKeyColumn}, ${createdAtColumn}
FROM ${userApiKeysTable}
ORDER BY ${userIdColumn}, ${createdAtColumn} ASC;`;
const oldestApiKeysPerUser = (await queryRunner.query(queryToGetUsersApiKeys)) as Array<
Partial<ApiKey>
>;
await Promise.all(
oldestApiKeysPerUser.map(
async (user: { userId: string; apiKey: string }) =>
await runQuery(
`UPDATE ${userTable} SET ${apiKeyColumn} = :apiKey WHERE ${idColumn} = :userId`,
user,
),
),
);
await dropTable('user_api_keys');
}
}
@@ -0,0 +1,23 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const processedDataTableName = 'processed_data';
export class CreateProcessedDataTable1726606152711 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(processedDataTableName)
.withColumns(
column('workflowId').varchar(36).notNull.primary,
column('value').varchar(255).notNull,
column('context').varchar(255).notNull.primary,
)
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(processedDataTableName);
}
}
@@ -0,0 +1,27 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class SeparateExecutionCreationFromStart1727427440136 implements ReversibleMigration {
async up({
schemaBuilder: { addColumns, column, dropNotNull },
runQuery,
escape,
}: MigrationContext) {
await addColumns('execution_entity', [
column('createdAt').notNull.timestamp().default('NOW()'),
]);
await dropNotNull('execution_entity', 'startedAt');
const executionEntity = escape.tableName('execution_entity');
const createdAt = escape.columnName('createdAt');
const startedAt = escape.columnName('startedAt');
// inaccurate for pre-migration rows but prevents `createdAt` from being nullable
await runQuery(`UPDATE ${executionEntity} SET ${createdAt} = ${startedAt};`);
}
async down({ schemaBuilder: { dropColumns, addNotNull } }: MigrationContext) {
await dropColumns('execution_entity', ['createdAt']);
await addNotNull('execution_entity', 'startedAt');
}
}
@@ -0,0 +1,23 @@
import assert from 'node:assert';
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
export class AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644
implements IrreversibleMigration
{
async up({ queryRunner, tablePrefix }: MigrationContext) {
// Check if the primary key already exists
const table = await queryRunner.getTable(`${tablePrefix}execution_annotation_tags`);
assert(table, 'execution_annotation_tags table not found');
const hasPrimaryKey = table.primaryColumns.length > 0;
if (!hasPrimaryKey) {
await queryRunner.createPrimaryKey(`${tablePrefix}execution_annotation_tags`, [
'annotationId',
'tagId',
]);
}
}
}
@@ -0,0 +1,24 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const processedDataTableName = 'processed_data';
export class UpdateProcessedDataValueColumnToText1729607673464 implements ReversibleMigration {
async up({ schemaBuilder: { addNotNull }, runQuery, tablePrefix }: MigrationContext) {
const prefixedTableName = `${tablePrefix}${processedDataTableName}`;
await runQuery(`ALTER TABLE ${prefixedTableName} ADD COLUMN value_temp TEXT;`);
await runQuery(`UPDATE ${prefixedTableName} SET value_temp = value;`);
await runQuery(`ALTER TABLE ${prefixedTableName} DROP COLUMN value;`);
await runQuery(`ALTER TABLE ${prefixedTableName} RENAME COLUMN value_temp TO value`);
await addNotNull(processedDataTableName, 'value');
}
async down({ schemaBuilder: { addNotNull }, runQuery, tablePrefix }: MigrationContext) {
const prefixedTableName = `${tablePrefix}${processedDataTableName}`;
await runQuery(`ALTER TABLE ${prefixedTableName} ADD COLUMN value_temp VARCHAR(255);`);
await runQuery(`UPDATE ${prefixedTableName} SET value_temp = value;`);
await runQuery(`ALTER TABLE ${prefixedTableName} DROP COLUMN value;`);
await runQuery(`ALTER TABLE ${prefixedTableName} RENAME COLUMN value_temp TO value`);
await addNotNull(processedDataTableName, 'value');
}
}
@@ -0,0 +1,10 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddProjectIcons1729607673469 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns('project', [column('icon').json]);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns('project', ['icon']);
}
}
@@ -0,0 +1,37 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const testEntityTableName = 'test_definition';
export class CreateTestDefinitionTable1730386903556 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(testEntityTableName)
.withColumns(
column('id').int.notNull.primary.autoGenerate,
column('name').varchar(255).notNull,
column('workflowId').varchar(36).notNull,
column('evaluationWorkflowId').varchar(36),
column('annotationTagId').varchar(16),
)
.withIndexOn('workflowId')
.withIndexOn('evaluationWorkflowId')
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('evaluationWorkflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'SET NULL',
})
.withForeignKey('annotationTagId', {
tableName: 'annotation_tag_entity',
columnName: 'id',
onDelete: 'SET NULL',
}).withTimestamps;
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(testEntityTableName);
}
}
@@ -0,0 +1,11 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddDescriptionToTestDefinition1731404028106 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns('test_definition', [column('description').text]);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns('test_definition', ['description']);
}
}
@@ -0,0 +1,24 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const testMetricEntityTableName = 'test_metric';
export class CreateTestMetricTable1732271325258 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(testMetricEntityTableName)
.withColumns(
column('id').varchar(36).primary.notNull,
column('name').varchar(255).notNull,
column('testDefinitionId').varchar(36).notNull,
)
.withIndexOn('testDefinitionId')
.withForeignKey('testDefinitionId', {
tableName: 'test_definition',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(testMetricEntityTableName);
}
}
@@ -0,0 +1,27 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const testRunTableName = 'test_run';
export class CreateTestRun1732549866705 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(testRunTableName)
.withColumns(
column('id').varchar(36).primary.notNull,
column('testDefinitionId').varchar(36).notNull,
column('status').varchar().notNull,
column('runAt').timestamp(),
column('completedAt').timestamp(),
column('metrics').json,
)
.withIndexOn('testDefinitionId')
.withForeignKey('testDefinitionId', {
tableName: 'test_definition',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(testRunTableName);
}
}
@@ -0,0 +1,22 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
// We have to use raw query migration instead of schemaBuilder helpers,
// because the typeorm schema builder implements addColumns by a table recreate for sqlite
// which causes weird issues with the migration
export class AddMockedNodesColumnToTestDefinition1733133775640 implements ReversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('test_definition');
const mockedNodesColumnName = escape.columnName('mockedNodes');
await runQuery(
`ALTER TABLE ${tableName} ADD COLUMN ${mockedNodesColumnName} JSON DEFAULT ('[]') NOT NULL`,
);
}
async down({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('test_definition');
const columnName = escape.columnName('mockedNodes');
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`);
}
}
@@ -0,0 +1,21 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddManagedColumnToCredentialsTable1734479635324 implements ReversibleMigration {
async up({ escape, runQuery, isSqlite }: MigrationContext) {
const tableName = escape.tableName('credentials_entity');
const columnName = escape.columnName('isManaged');
const defaultValue = isSqlite ? 0 : 'FALSE';
await runQuery(
`ALTER TABLE ${tableName} ADD COLUMN ${columnName} BOOLEAN NOT NULL DEFAULT ${defaultValue}`,
);
}
async down({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('credentials_entity');
const columnName = escape.columnName('isManaged');
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`);
}
}
@@ -0,0 +1,31 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const columns = ['totalCases', 'passedCases', 'failedCases'] as const;
export class AddStatsColumnsToTestRun1736172058779 implements ReversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('test_run');
const columnNames = columns.map((name) => escape.columnName(name));
// Values can be NULL only if the test run is new, otherwise they must be non-negative integers.
// Test run might be cancelled or interrupted by unexpected error at any moment, so values can be either NULL or non-negative integers.
for (const name of columnNames) {
await runQuery(`ALTER TABLE ${tableName} ADD COLUMN ${name} INT CHECK(
CASE
WHEN status = 'new' THEN ${name} IS NULL
WHEN status in ('cancelled', 'error') THEN ${name} IS NULL OR ${name} >= 0
ELSE ${name} >= 0
END
)`);
}
}
async down({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('test_run');
const columnNames = columns.map((name) => escape.columnName(name));
for (const name of columnNames) {
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${name}`);
}
}
}
@@ -0,0 +1,47 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const testCaseExecutionTableName = 'test_case_execution';
export class CreateTestCaseExecutionTable1736947513045 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(testCaseExecutionTableName)
.withColumns(
column('id').varchar(36).primary.notNull,
column('testRunId').varchar(36).notNull,
column('pastExecutionId').int, // Might be null if execution was deleted after the test run
column('executionId').int, // Execution of the workflow under test. Might be null if execution was deleted after the test run
column('evaluationExecutionId').int, // Execution of the evaluation workflow. Might be null if execution was deleted after the test run, or if the test run was cancelled
column('status').varchar().notNull,
column('runAt').timestamp(),
column('completedAt').timestamp(),
column('errorCode').varchar(),
column('errorDetails').json,
column('metrics').json,
)
.withIndexOn('testRunId')
.withForeignKey('testRunId', {
tableName: 'test_run',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('pastExecutionId', {
tableName: 'execution_entity',
columnName: 'id',
onDelete: 'SET NULL',
})
.withForeignKey('executionId', {
tableName: 'execution_entity',
columnName: 'id',
onDelete: 'SET NULL',
})
.withForeignKey('evaluationExecutionId', {
tableName: 'execution_entity',
columnName: 'id',
onDelete: 'SET NULL',
}).withTimestamps;
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(testCaseExecutionTableName);
}
}
@@ -0,0 +1,24 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
// We have to use raw query migration instead of schemaBuilder helpers,
// because the typeorm schema builder implements addColumns by a table recreate for sqlite
// which causes weird issues with the migration
export class AddErrorColumnsToTestRuns1737715421462 implements ReversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('test_run');
const errorCodeColumnName = escape.columnName('errorCode');
const errorDetailsColumnName = escape.columnName('errorDetails');
await runQuery(`ALTER TABLE ${tableName} ADD COLUMN ${errorCodeColumnName} VARCHAR(255);`);
await runQuery(`ALTER TABLE ${tableName} ADD COLUMN ${errorDetailsColumnName} TEXT;`);
}
async down({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('test_run');
const errorCodeColumnName = escape.columnName('errorCode');
const errorDetailsColumnName = escape.columnName('errorDetails');
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${errorCodeColumnName};`);
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${errorDetailsColumnName};`);
}
}
@@ -0,0 +1,60 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class CreateFolderTable1738709609940 implements ReversibleMigration {
async up({ runQuery, escape, schemaBuilder: { createTable, column } }: MigrationContext) {
const workflowTable = escape.tableName('workflow_entity');
const workflowFolderId = escape.columnName('parentFolderId');
const folderTable = escape.tableName('folder');
const folderId = escape.columnName('id');
await createTable('folder')
.withColumns(
column('id').varchar(36).primary.notNull,
column('name').varchar(128).notNull,
column('parentFolderId').varchar(36).default(null),
column('projectId').varchar(36).notNull,
)
.withForeignKey('projectId', {
tableName: 'project',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('parentFolderId', {
tableName: 'folder',
columnName: 'id',
onDelete: 'CASCADE',
})
.withIndexOn(['projectId', 'id'], true).withTimestamps;
await createTable('folder_tag')
.withColumns(
column('folderId').varchar(36).primary.notNull,
column('tagId').varchar(36).primary.notNull,
)
.withForeignKey('folderId', {
tableName: 'folder',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('tagId', {
tableName: 'tag_entity',
columnName: 'id',
onDelete: 'CASCADE',
});
await runQuery(
`ALTER TABLE ${workflowTable} ADD COLUMN ${workflowFolderId} VARCHAR(36) DEFAULT NULL REFERENCES ${folderTable}(${folderId}) ON DELETE SET NULL`,
);
}
async down({ runQuery, escape, schemaBuilder: { dropTable } }: MigrationContext) {
const workflowTable = escape.tableName('workflow_entity');
const workflowFolderId = escape.columnName('parentFolderId');
await runQuery(`ALTER TABLE ${workflowTable} DROP COLUMN ${workflowFolderId}`);
await dropTable('folder_tag');
await dropTable('folder');
}
}
@@ -0,0 +1,106 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const names = {
// table names
t: {
analyticsMetadata: 'analytics_metadata',
analyticsRaw: 'analytics_raw',
analyticsByPeriod: 'analytics_by_period',
workflowEntity: 'workflow_entity',
project: 'project',
},
// column names by table
c: {
analyticsMetadata: {
metaId: 'metaId',
projectId: 'projectId',
workflowId: 'workflowId',
},
analyticsRaw: {
metaId: 'metaId',
},
analyticsByPeriod: {
metaId: 'metaId',
type: 'type',
periodUnit: 'periodUnit',
periodStart: 'periodStart',
},
project: {
id: 'id',
},
workflowEntity: {
id: 'id',
},
},
};
export class CreateAnalyticsTables1739549398681 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(names.t.analyticsMetadata)
.withColumns(
column(names.c.analyticsMetadata.metaId).int.primary.autoGenerate2,
column(names.c.analyticsMetadata.workflowId).varchar(16),
column(names.c.analyticsMetadata.projectId).varchar(36),
column('workflowName').varchar(128).notNull,
column('projectName').varchar(255).notNull,
)
.withForeignKey(names.c.analyticsMetadata.workflowId, {
tableName: names.t.workflowEntity,
columnName: names.c.workflowEntity.id,
onDelete: 'SET NULL',
})
.withForeignKey(names.c.analyticsMetadata.projectId, {
tableName: names.t.project,
columnName: names.c.project.id,
onDelete: 'SET NULL',
});
const typeComment = '0: time_saved_minutes, 1: runtime_milliseconds, 2: success, 3: failure';
await createTable(names.t.analyticsRaw)
.withColumns(
column('id').int.primary.autoGenerate2,
column(names.c.analyticsRaw.metaId).int.notNull,
column('type').int.notNull.comment(typeComment),
column('value').int.notNull,
column('timestamp').timestampNoTimezone(0).default('CURRENT_TIMESTAMP').notNull,
)
.withForeignKey(names.c.analyticsRaw.metaId, {
tableName: names.t.analyticsMetadata,
columnName: names.c.analyticsMetadata.metaId,
onDelete: 'CASCADE',
});
await createTable(names.t.analyticsByPeriod)
.withColumns(
column('id').int.primary.autoGenerate2,
column(names.c.analyticsByPeriod.metaId).int.notNull,
column(names.c.analyticsByPeriod.type).int.notNull.comment(typeComment),
column('value').int.notNull,
column(names.c.analyticsByPeriod.periodUnit).int.notNull.comment(
'0: hour, 1: day, 2: week',
),
column(names.c.analyticsByPeriod.periodStart).timestampNoTimezone(0),
)
.withForeignKey(names.c.analyticsByPeriod.metaId, {
tableName: names.t.analyticsMetadata,
columnName: names.c.analyticsMetadata.metaId,
onDelete: 'CASCADE',
})
.withIndexOn(
[
names.c.analyticsByPeriod.periodStart,
names.c.analyticsByPeriod.type,
names.c.analyticsByPeriod.periodUnit,
names.c.analyticsByPeriod.metaId,
],
true,
);
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(names.t.analyticsRaw);
await dropTable(names.t.analyticsByPeriod);
await dropTable(names.t.analyticsMetadata);
}
}
@@ -0,0 +1,112 @@
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
const names = {
// table names
t: {
analyticsMetadata: 'analytics_metadata',
analyticsRaw: 'analytics_raw',
analyticsByPeriod: 'analytics_by_period',
insightsMetadata: 'insights_metadata',
insightsRaw: 'insights_raw',
insightsByPeriod: 'insights_by_period',
workflowEntity: 'workflow_entity',
project: 'project',
},
// column names by table
c: {
insightsMetadata: {
metaId: 'metaId',
projectId: 'projectId',
workflowId: 'workflowId',
},
insightsRaw: {
metaId: 'metaId',
},
insightsByPeriod: {
metaId: 'metaId',
type: 'type',
periodUnit: 'periodUnit',
periodStart: 'periodStart',
},
project: {
id: 'id',
},
workflowEntity: {
id: 'id',
},
},
};
export class RenameAnalyticsToInsights1741167584277 implements IrreversibleMigration {
async up({ schemaBuilder: { createTable, column, dropTable } }: MigrationContext) {
// Until the insights feature is released we're dropping the tables instead
// of migrating them.
await dropTable(names.t.analyticsRaw);
await dropTable(names.t.analyticsByPeriod);
await dropTable(names.t.analyticsMetadata);
await createTable(names.t.insightsMetadata)
.withColumns(
column(names.c.insightsMetadata.metaId).int.primary.autoGenerate2,
column(names.c.insightsMetadata.workflowId).varchar(16),
column(names.c.insightsMetadata.projectId).varchar(36),
column('workflowName').varchar(128).notNull,
column('projectName').varchar(255).notNull,
)
.withForeignKey(names.c.insightsMetadata.workflowId, {
tableName: names.t.workflowEntity,
columnName: names.c.workflowEntity.id,
onDelete: 'SET NULL',
})
.withForeignKey(names.c.insightsMetadata.projectId, {
tableName: names.t.project,
columnName: names.c.project.id,
onDelete: 'SET NULL',
})
.withIndexOn(names.c.insightsMetadata.workflowId, true);
const typeComment = '0: time_saved_minutes, 1: runtime_milliseconds, 2: success, 3: failure';
await createTable(names.t.insightsRaw)
.withColumns(
column('id').int.primary.autoGenerate2,
column(names.c.insightsRaw.metaId).int.notNull,
column('type').int.notNull.comment(typeComment),
column('value').int.notNull,
column('timestamp').timestampTimezone(0).default('CURRENT_TIMESTAMP').notNull,
)
.withForeignKey(names.c.insightsRaw.metaId, {
tableName: names.t.insightsMetadata,
columnName: names.c.insightsMetadata.metaId,
onDelete: 'CASCADE',
});
await createTable(names.t.insightsByPeriod)
.withColumns(
column('id').int.primary.autoGenerate2,
column(names.c.insightsByPeriod.metaId).int.notNull,
column(names.c.insightsByPeriod.type).int.notNull.comment(typeComment),
column('value').int.notNull,
column(names.c.insightsByPeriod.periodUnit).int.notNull.comment('0: hour, 1: day, 2: week'),
column(names.c.insightsByPeriod.periodStart)
.default('CURRENT_TIMESTAMP')
.timestampTimezone(0),
)
.withForeignKey(names.c.insightsByPeriod.metaId, {
tableName: names.t.insightsMetadata,
columnName: names.c.insightsMetadata.metaId,
onDelete: 'CASCADE',
})
.withIndexOn(
[
names.c.insightsByPeriod.periodStart,
names.c.insightsByPeriod.type,
names.c.insightsByPeriod.periodUnit,
names.c.insightsByPeriod.metaId,
],
true,
);
}
}
@@ -0,0 +1,41 @@
import type { GlobalRole } from '@n8n/permissions';
import { getApiKeyScopesForRole } from '@n8n/permissions';
import { GLOBAL_ROLES } from '../../constants';
import { ApiKey } from '../../entities';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
type ApiKeyWithRole = { id: string; role: GlobalRole };
export class AddScopesColumnToApiKeys1742918400000 implements ReversibleMigration {
async up({
runQuery,
escape,
queryRunner,
schemaBuilder: { addColumns, column },
}: MigrationContext) {
await addColumns('user_api_keys', [column('scopes').json]);
const userApiKeysTable = escape.tableName('user_api_keys');
const userTable = escape.tableName('user');
const idColumn = escape.columnName('id');
const userIdColumn = escape.columnName('userId');
const roleColumn = escape.columnName('role');
const apiKeysWithRoles = await runQuery<ApiKeyWithRole[]>(
`SELECT ${userApiKeysTable}.${idColumn} AS id, ${userTable}.${roleColumn} AS role FROM ${userApiKeysTable} JOIN ${userTable} ON ${userTable}.${idColumn} = ${userApiKeysTable}.${userIdColumn}`,
);
for (const { id, role } of apiKeysWithRoles) {
const dbRole = GLOBAL_ROLES[role];
const scopes = getApiKeyScopesForRole({
role: dbRole,
});
await queryRunner.manager.update(ApiKey, { id }, { scopes });
}
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns('user_api_keys', ['scopes']);
}
}
@@ -0,0 +1,65 @@
import type { MigrationContext, IrreversibleMigration } from '../migration-types';
const testRunTableName = 'test_run';
const testCaseExecutionTableName = 'test_case_execution';
export class ClearEvaluation1745322634000 implements IrreversibleMigration {
async up({
schemaBuilder: { dropTable, column, createTable },
queryRunner,
tablePrefix,
isSqlite,
isPostgres,
}: MigrationContext) {
// Drop test_metric, test_definition
await dropTable(testCaseExecutionTableName);
await dropTable(testRunTableName);
await dropTable('test_metric');
if (isSqlite) {
await queryRunner.query(`DROP TABLE IF EXISTS ${tablePrefix}test_definition;`);
} else if (isPostgres) {
await queryRunner.query(`DROP TABLE IF EXISTS ${tablePrefix}test_definition CASCADE;`);
}
await createTable(testRunTableName)
.withColumns(
column('id').varchar(36).primary.notNull,
column('workflowId').varchar(36).notNull,
column('status').varchar().notNull,
column('errorCode').varchar(),
column('errorDetails').json,
column('runAt').timestamp(),
column('completedAt').timestamp(),
column('metrics').json,
)
.withIndexOn('workflowId')
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
await createTable(testCaseExecutionTableName)
.withColumns(
column('id').varchar(36).primary.notNull,
column('testRunId').varchar(36).notNull,
column('executionId').int, // Execution of the workflow under test. Might be null if execution was deleted after the test run
column('status').varchar().notNull,
column('runAt').timestamp(),
column('completedAt').timestamp(),
column('errorCode').varchar(),
column('errorDetails').json,
column('metrics').json,
)
.withIndexOn('testRunId')
.withForeignKey('testRunId', {
tableName: 'test_run',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('executionId', {
tableName: 'execution_entity',
columnName: 'id',
onDelete: 'SET NULL',
}).withTimestamps;
}
}
@@ -0,0 +1,22 @@
import type { ReversibleMigration, MigrationContext } from '../migration-types';
const columnName = 'rootCount';
const tableName = 'workflow_statistics';
export class AddWorkflowStatisticsRootCount1745587087521 implements ReversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const escapedTableName = escape.tableName(tableName);
const escapedColumnName = escape.columnName(columnName);
await runQuery(
`ALTER TABLE ${escapedTableName} ADD COLUMN ${escapedColumnName} INTEGER DEFAULT 0`,
);
}
async down({ escape, runQuery }: MigrationContext) {
const escapedTableName = escape.tableName(tableName);
const escapedColumnName = escape.columnName(columnName);
await runQuery(`ALTER TABLE ${escapedTableName} DROP COLUMN ${escapedColumnName}`);
}
}
@@ -0,0 +1,22 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const columnName = 'isArchived';
const tableName = 'workflow_entity';
export class AddWorkflowArchivedColumn1745934666076 implements ReversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const escapedTableName = escape.tableName(tableName);
const escapedColumnName = escape.columnName(columnName);
await runQuery(
`ALTER TABLE ${escapedTableName} ADD COLUMN ${escapedColumnName} BOOLEAN NOT NULL DEFAULT FALSE`,
);
}
async down({ escape, runQuery }: MigrationContext) {
const escapedTableName = escape.tableName(tableName);
const escapedColumnName = escape.columnName(columnName);
await runQuery(`ALTER TABLE ${escapedTableName} DROP COLUMN ${escapedColumnName}`);
}
}
@@ -0,0 +1,13 @@
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
/**
* Drop the `role` table introduced by `CreateUserManagement1646992772331` and later
* abandoned with the move to `@n8n/permissions` in https://github.com/n8n-io/n8n/pull/7650
*
* Irreversible as there is no use case for restoring a long unused table.
*/
export class DropRoleTable1745934666077 implements IrreversibleMigration {
async up({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable('role');
}
}
@@ -0,0 +1,20 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const columnName = 'description';
const tableName = 'project';
export class AddProjectDescriptionColumn1747824239000 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} VARCHAR(512)`);
}
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,20 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const columnName = 'lastActiveAt';
const tableName = 'user';
export class AddLastActiveAtColumnToUser1750252139166 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} DATE NULL`);
}
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,32 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
/*
* We introduce a scope table, this will hold all scopes that we know about.
*
* The scope table should never be edited by users, on every startup
* the system will make sure that all scopes that it knows about are stored
* in here.
*
* ColumnName | Type | Description
* =================================
* slug | Text | Unique identifier of the scope for example: 'project:create'
* displayName | Text | Name used to display in the UI
* description | Text | Text describing the scope in more detail of users
*/
export class AddScopeTables1750252139166 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable('scope').withColumns(
column('slug')
.varchar(128)
.primary.notNull.comment('Unique identifier of the scope for example: "project:create"'),
column('displayName').text.default(null).comment('Name used to display in the UI'),
column('description')
.text.default(null)
.comment('Text describing the scope in more detail of users'),
);
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable('scope');
}
}
@@ -0,0 +1,82 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
/*
* We introduce roles table, this will hold all roles that we know about
*
* There are roles that can't be edited by users, these are marked as system-only and will
* be managed by the system itself. On every startup, the system will ensure
* that these roles are synchronized.
*
* ColumnName | Type | Description
* =================================
* slug | Text | Unique identifier of the role for example: 'global:owner'
* displayName | Text | Name used to display in the UI
* description | Text | Text describing the scope in more detail of users
* roleType | Text | Text type of role, such as 'global', 'project', etc.
* systemRole | Bool | Indicates if the role is managed by the system and cannot be edited by users
*
* For the role table there is a junction table that will hold the
* relationships between the roles and the scopes that are associated with them.
*/
export class AddRolesTables1750252139167 implements ReversibleMigration {
async up({
schemaBuilder: { createTable, column, createIndex },
queryRunner,
tablePrefix,
}: MigrationContext) {
await createTable('role').withColumns(
column('slug')
.varchar(128)
.primary.notNull.comment('Unique identifier of the role for example: "global:owner"'),
column('displayName').text.default(null).comment('Name used to display in the UI'),
column('description')
.text.default(null)
.comment('Text describing the scope in more detail of users'),
column('roleType')
.text.default(null)
.comment('Type of the role, e.g., global, project, or workflow'),
column('systemRole')
.bool.default(false)
.notNull.comment('Indicates if the role is managed by the system and cannot be edited'),
);
await queryRunner.query(
`CREATE TABLE ${tablePrefix}role_scope (
"roleSlug" VARCHAR(128) NOT NULL,
"scopeSlug" VARCHAR(128) NOT NULL,
CONSTRAINT "PK_${tablePrefix}role_scope" PRIMARY KEY ("roleSlug", "scopeSlug"),
CONSTRAINT "FK_${tablePrefix}role" FOREIGN KEY ("roleSlug") REFERENCES ${tablePrefix}role ("slug") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "FK_${tablePrefix}scope" FOREIGN KEY ("scopeSlug") REFERENCES "${tablePrefix}scope" ("slug") ON DELETE CASCADE ON UPDATE CASCADE
);`,
);
await createIndex('role_scope', ['scopeSlug']);
/*
await createTable('role_scope')
.withColumns(
column('id').int.primary.autoGenerate2,
column('roleSlug').varchar(128).notNull,
column('scopeSlug').varchar(128).notNull,
)
.withForeignKey('roleSlug', {
tableName: 'role',
columnName: 'slug',
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
})
.withForeignKey('scopeSlug', {
tableName: 'scope',
columnName: 'slug',
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
})
.withIndexOn('scopeSlug') // For fast lookup of which roles have access to a scope
.withIndexOn(['roleSlug', 'scopeSlug'], true); */
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable('role_scope');
await dropTable('role');
}
}
@@ -0,0 +1,59 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
/*
* This migration links the role table to the user table, by adding a new column 'roleSlug'
* to the user table. It also ensures that all users have a valid role set in the 'roleSlug' column.
* The migration will insert the global roles that we need into the role table if they do not exist.
*
* The old 'role' column in the user table will be removed in a later migration.
*/
export class LinkRoleToUserTable1750252139168 implements ReversibleMigration {
async up({
schemaBuilder: { addForeignKey, addColumns, column },
escape,
dbType,
runQuery,
}: MigrationContext) {
const roleTableName = escape.tableName('role');
const userTableName = escape.tableName('user');
const slugColumn = escape.columnName('slug');
const roleColumn = escape.columnName('role');
const roleSlugColumn = escape.columnName('roleSlug');
const roleTypeColumn = escape.columnName('roleType');
const systemRoleColumn = escape.columnName('systemRole');
const isPostgresOrSqlite = dbType === 'postgresdb' || dbType === 'sqlite';
const upsertQuery = isPostgresOrSqlite
? `INSERT INTO ${roleTableName} (${slugColumn}, ${roleTypeColumn}, ${systemRoleColumn}) VALUES (:slug, :roleType, :systemRole) ON CONFLICT DO NOTHING`
: `INSERT IGNORE INTO ${roleTableName} (${slugColumn}, ${roleTypeColumn}, ${systemRoleColumn}) VALUES (:slug, :roleType, :systemRole)`;
// Make sure that the global roles that we need exist
for (const role of ['global:owner', 'global:admin', 'global:member']) {
await runQuery(upsertQuery, {
slug: role,
roleType: 'global',
systemRole: true,
});
}
await addColumns('user', [column('roleSlug').varchar(128).default("'global:member'").notNull]);
await runQuery(
`UPDATE ${userTableName} SET ${roleSlugColumn} = ${roleColumn} WHERE ${roleColumn} != ${roleSlugColumn}`,
);
// Fallback to 'global:member' for users that do not have a correct role set
// This should not happen in a correctly set up system, but we want to ensure
// that all users have a role set, before we add the foreign key constraint
await runQuery(
`UPDATE ${userTableName} SET ${roleSlugColumn} = 'global:member' WHERE NOT EXISTS (SELECT 1 FROM ${roleTableName} WHERE ${slugColumn} = ${roleSlugColumn})`,
);
await addForeignKey('user', 'roleSlug', ['role', 'slug']);
}
async down({ schemaBuilder: { dropForeignKey, dropColumns } }: MigrationContext) {
await dropForeignKey('user', 'roleSlug', ['role', 'slug']);
await dropColumns('user', ['roleSlug']);
}
}
@@ -0,0 +1,48 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
/*
* This migration removes the old 'role' column from the 'user' table
* and ensures that all users have a valid role set in the 'roleSlug' column.
* It also ensures that the 'roleSlug' column is correctly populated with the
* values from the 'role' column before dropping it.
* This is a reversible migration, allowing the role column to be restored if needed.
*/
export class RemoveOldRoleColumn1750252139170 implements ReversibleMigration {
async up({ schemaBuilder: { dropColumns }, escape, runQuery }: MigrationContext) {
const roleTableName = escape.tableName('role');
const userTableName = escape.tableName('user');
const slugColumn = escape.columnName('slug');
const roleColumn = escape.columnName('role');
const roleSlugColumn = escape.columnName('roleSlug');
// Fallback to 'global:member' for users that do not have a correct role set
// This should not happen in a correctly set up system, but we want to ensure
// that all users have a role set, before we add the foreign key constraint
await runQuery(
`UPDATE ${userTableName} SET ${roleSlugColumn} = 'global:member', ${roleColumn} = 'global:member' WHERE NOT EXISTS (SELECT 1 FROM ${roleTableName} WHERE ${slugColumn} = ${roleColumn})`,
);
await runQuery(
`UPDATE ${userTableName} SET ${roleSlugColumn} = ${roleColumn} WHERE ${roleColumn} != ${roleSlugColumn}`,
);
await dropColumns('user', ['role']);
}
async down({ schemaBuilder: { addColumns, column }, escape, runQuery }: MigrationContext) {
const userTableName = escape.tableName('user');
const roleColumn = escape.columnName('role');
const roleSlugColumn = escape.columnName('roleSlug');
await addColumns('user', [column('role').varchar(128).default("'global:member'").notNull]);
await runQuery(
`UPDATE ${userTableName} SET ${roleColumn} = ${roleSlugColumn} WHERE ${roleSlugColumn} != ${roleColumn}`,
);
// Fallback to 'global:member' for users that do not have a correct role set
await runQuery(
`UPDATE ${userTableName} SET ${roleColumn} = 'global:member' WHERE NOT EXISTS (SELECT 1 FROM role WHERE slug = ${roleColumn})`,
);
}
}
@@ -0,0 +1,11 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddInputsOutputsToTestCaseExecution1752669793000 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns('test_case_execution', [column('inputs').json, column('outputs').json]);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns('test_case_execution', ['inputs', 'outputs']);
}
}
@@ -0,0 +1,46 @@
import { PROJECT_ROLES, PROJECT_VIEWER_ROLE } from '../../constants';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
/*
* This migration links the role table to the project relation table, by adding a new foreign key on the 'role' column
* It also ensures that all project relations have a valid role set in the 'role' column.
* The migration will insert the project roles that we need into the role table if they do not exist.
*/
export class LinkRoleToProjectRelationTable1753953244168 implements ReversibleMigration {
async up({ schemaBuilder: { addForeignKey }, escape, dbType, runQuery }: MigrationContext) {
const roleTableName = escape.tableName('role');
const projectRelationTableName = escape.tableName('project_relation');
const slugColumn = escape.columnName('slug');
const roleColumn = escape.columnName('role');
const roleTypeColumn = escape.columnName('roleType');
const systemRoleColumn = escape.columnName('systemRole');
const isPostgresOrSqlite = dbType === 'postgresdb' || dbType === 'sqlite';
const query = isPostgresOrSqlite
? `INSERT INTO ${roleTableName} (${slugColumn}, ${roleTypeColumn}, ${systemRoleColumn}) VALUES (:slug, :roleType, :systemRole) ON CONFLICT DO NOTHING`
: `INSERT IGNORE INTO ${roleTableName} (${slugColumn}, ${roleTypeColumn}, ${systemRoleColumn}) VALUES (:slug, :roleType, :systemRole)`;
// Make sure that the project roles that we need exist
for (const role of Object.values(PROJECT_ROLES)) {
await runQuery(query, {
slug: role.slug,
roleType: role.roleType,
systemRole: role.systemRole,
});
}
// Fallback to 'project:viewer' for users that do not have a correct role set
// This should not happen in a correctly set up system, but we want to ensure
// that all users have a role set, before we add the foreign key constraint
await runQuery(
`UPDATE ${projectRelationTableName} SET ${roleColumn} = '${PROJECT_VIEWER_ROLE.slug}' WHERE NOT EXISTS (SELECT 1 FROM ${roleTableName} WHERE ${slugColumn} = ${roleColumn})`,
);
await addForeignKey('project_relation', 'role', ['role', 'slug']);
}
async down({ schemaBuilder: { dropForeignKey } }: MigrationContext) {
await dropForeignKey('project_relation', 'role', ['role', 'slug']);
}
}
@@ -0,0 +1,46 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const DATA_STORE_TABLE_NAME = 'data_store';
const DATA_STORE_COLUMN_TABLE_NAME = 'data_store_column';
export class CreateDataStoreTables1754475614601 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(DATA_STORE_TABLE_NAME)
.withColumns(
column('id').varchar(36).primary,
column('name').varchar(128).notNull,
column('projectId').varchar(36).notNull,
column('sizeBytes').int.default(0).notNull,
)
.withForeignKey('projectId', {
tableName: 'project',
columnName: 'id',
onDelete: 'CASCADE',
})
.withUniqueConstraintOn(['projectId', 'name']).withTimestamps;
await createTable(DATA_STORE_COLUMN_TABLE_NAME)
.withColumns(
column('id').varchar(36).primary.notNull,
column('name').varchar(128).notNull,
column('type')
.varchar(32)
.notNull.comment(
'Expected: string, number, boolean, or date (not enforced as a constraint)',
),
column('index').int.notNull.comment('Column order, starting from 0 (0 = first column)'),
column('dataStoreId').varchar(36).notNull,
)
.withForeignKey('dataStoreId', {
tableName: DATA_STORE_TABLE_NAME,
columnName: 'id',
onDelete: 'CASCADE',
})
.withUniqueConstraintOn(['dataStoreId', 'name']).withTimestamps;
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(DATA_STORE_COLUMN_TABLE_NAME);
await dropTable(DATA_STORE_TABLE_NAME);
}
}
@@ -0,0 +1,84 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const TABLE_TABLE_NAME_BEFORE = 'data_store';
const COLUMN_TABLE_NAME_BEFORE = 'data_store_column';
const TABLE_TABLE_NAME_AFTER = 'data_table';
const COLUMN_TABLE_NAME_AFTER = 'data_table_column';
export class ReplaceDataStoreTablesWithDataTables1754475614602 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column, dropTable } }: MigrationContext) {
await dropTable(COLUMN_TABLE_NAME_BEFORE);
await dropTable(TABLE_TABLE_NAME_BEFORE);
await createTable(TABLE_TABLE_NAME_AFTER)
.withColumns(
column('id').varchar(36).primary,
column('name').varchar(128).notNull,
column('projectId').varchar(36).notNull,
)
.withForeignKey('projectId', {
tableName: 'project',
columnName: 'id',
onDelete: 'CASCADE',
})
.withUniqueConstraintOn(['projectId', 'name']).withTimestamps;
await createTable(COLUMN_TABLE_NAME_AFTER)
.withColumns(
column('id').varchar(36).primary.notNull,
column('name').varchar(128).notNull,
column('type')
.varchar(32)
.notNull.comment(
'Expected: string, number, boolean, or date (not enforced as a constraint)',
),
column('index').int.notNull.comment('Column order, starting from 0 (0 = first column)'),
column('dataTableId').varchar(36).notNull,
)
.withForeignKey('dataTableId', {
tableName: TABLE_TABLE_NAME_AFTER,
columnName: 'id',
onDelete: 'CASCADE',
})
.withUniqueConstraintOn(['dataTableId', 'name']).withTimestamps;
}
async down({ schemaBuilder: { createTable, column, dropTable } }: MigrationContext) {
await dropTable(COLUMN_TABLE_NAME_AFTER);
await dropTable(TABLE_TABLE_NAME_AFTER);
await createTable(TABLE_TABLE_NAME_BEFORE)
.withColumns(
column('id').varchar(36).primary,
column('name').varchar(128).notNull,
column('projectId').varchar(36).notNull,
column('sizeBytes').int.default(0).notNull,
)
.withForeignKey('projectId', {
tableName: 'project',
columnName: 'id',
onDelete: 'CASCADE',
})
.withUniqueConstraintOn(['projectId', 'name']).withTimestamps;
await createTable(COLUMN_TABLE_NAME_BEFORE)
.withColumns(
column('id').varchar(36).primary.notNull,
column('name').varchar(128).notNull,
column('type')
.varchar(32)
.notNull.comment(
'Expected: string, number, boolean, or date (not enforced as a constraint)',
),
column('index').int.notNull.comment('Column order, starting from 0 (0 = first column)'),
column('dataStoreId').varchar(36).notNull,
)
.withForeignKey('dataStoreId', {
tableName: TABLE_TABLE_NAME_BEFORE,
columnName: 'id',
onDelete: 'CASCADE',
})
.withUniqueConstraintOn(['dataStoreId', 'name']).withTimestamps;
}
}
@@ -0,0 +1,46 @@
import { Column } from '../dsl/column';
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
const ROLE_TABLE_NAME = 'role';
const PROJECT_RELATION_TABLE_NAME = 'project_relation';
const USER_TABLE_NAME = 'user';
const PROJECT_RELATION_ROLE_IDX_NAME = 'project_relation_role_idx';
const PROJECT_RELATION_ROLE_PROJECT_IDX_NAME = 'project_relation_role_project_idx';
const USER_ROLE_IDX_NAME = 'user_role_idx';
export class AddTimestampsToRoleAndRoleIndexes1756906557570 implements IrreversibleMigration {
async up({ schemaBuilder, queryRunner, tablePrefix }: MigrationContext) {
// This loads the table metadata from the database and
// feeds the query runners cache with the table metadata
// Not doing this, seems to get TypeORM to wrongfully try to
// add the columns twice in the same statement.
await queryRunner.getTable(`${tablePrefix}${USER_TABLE_NAME}`);
await schemaBuilder.addColumns(ROLE_TABLE_NAME, [
new Column('createdAt').timestampTimezone().notNull.default('NOW()'),
new Column('updatedAt').timestampTimezone().notNull.default('NOW()'),
]);
// This index should allow us to efficiently query project relations by their role
// This will be used for counting how many users have a specific project role
await schemaBuilder.createIndex(
PROJECT_RELATION_TABLE_NAME,
['role'],
false,
PROJECT_RELATION_ROLE_IDX_NAME,
);
// This index should allow us to efficiently query project relations by their role and project
// This will be used for counting how many users in a specific project have a specific project role
await schemaBuilder.createIndex(
PROJECT_RELATION_TABLE_NAME,
['projectId', 'role'],
false,
PROJECT_RELATION_ROLE_PROJECT_IDX_NAME,
);
// This index should allow us to efficiently query users by their role slug
// This will be used for counting how many users have a specific global role
await schemaBuilder.createIndex(USER_TABLE_NAME, ['roleSlug'], false, USER_ROLE_IDX_NAME);
}
}
@@ -0,0 +1,13 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddAudienceColumnToApiKeys1758731786132 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns('user_api_keys', [
column('audience').varchar().notNull.default("'public-api'"),
]);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns('user_api_keys', ['audience']);
}
}
@@ -0,0 +1,87 @@
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
const INSIGHTS_RAW_TABLE_NAME = 'insights_raw';
const INSIGHTS_RAW_TEMP_TABLE_NAME = 'temp_insights_raw';
const INSIGHTS_BY_PERIOD_TABLE_NAME = 'insights_by_period';
const INSIGHTS_BY_PERIOD_TEMP_TABLE_NAME = 'temp_insights_by_period';
const INSIGHTS_METADATA_TABLE_NAME = 'insights_metadata';
const VALUE_COLUMN_NAME = 'value';
export class ChangeValueTypesForInsights1759399811000 implements IrreversibleMigration {
async up({
isSqlite,
isPostgres,
escape,
copyTable,
queryRunner,
schemaBuilder: { createTable, column, dropTable },
}: MigrationContext) {
const insightsRawTable = escape.tableName(INSIGHTS_RAW_TABLE_NAME);
const insightsByPeriodTable = escape.tableName(INSIGHTS_BY_PERIOD_TABLE_NAME);
const valueColumnName = escape.columnName(VALUE_COLUMN_NAME);
if (isSqlite) {
const tempInsightsByPeriodTable = escape.tableName(INSIGHTS_BY_PERIOD_TEMP_TABLE_NAME);
const tempInsightsRawTable = escape.tableName(INSIGHTS_RAW_TEMP_TABLE_NAME);
const typeComment = '0: time_saved_minutes, 1: runtime_milliseconds, 2: success, 3: failure';
// Create temporary raw table with new value type, copy data, remove the original table and rename the temporary table
await createTable(INSIGHTS_RAW_TEMP_TABLE_NAME)
.withColumns(
column('id').int.primary.autoGenerate2,
column('metaId').int.notNull,
column('type').int.notNull.comment(typeComment),
column('value').bigint.notNull,
column('timestamp').timestampTimezone(0).default('CURRENT_TIMESTAMP').notNull,
)
.withForeignKey('metaId', {
tableName: INSIGHTS_METADATA_TABLE_NAME,
columnName: 'metaId',
onDelete: 'CASCADE',
});
// Copy data from the original table to the temporary table
await copyTable(INSIGHTS_RAW_TABLE_NAME, INSIGHTS_RAW_TEMP_TABLE_NAME);
// drop the original table
await dropTable(INSIGHTS_RAW_TABLE_NAME);
// rename the temporary table to the original table name
await queryRunner.query(`ALTER TABLE ${tempInsightsRawTable} RENAME TO ${insightsRawTable};`);
await createTable(INSIGHTS_BY_PERIOD_TEMP_TABLE_NAME)
.withColumns(
column('id').int.primary.autoGenerate2,
column('metaId').int.notNull,
column('type').int.notNull.comment(typeComment),
column('value').bigint.notNull,
column('periodUnit').int.notNull.comment('0: hour, 1: day, 2: week'),
column('periodStart').default('CURRENT_TIMESTAMP').timestampTimezone(0),
)
.withForeignKey('metaId', {
tableName: INSIGHTS_METADATA_TABLE_NAME,
columnName: 'metaId',
onDelete: 'CASCADE',
})
.withIndexOn(['periodStart', 'type', 'periodUnit', 'metaId'], true);
// Copy data from the original table to the temporary table
await copyTable(INSIGHTS_BY_PERIOD_TABLE_NAME, INSIGHTS_BY_PERIOD_TEMP_TABLE_NAME);
// drop the original table
await dropTable(INSIGHTS_BY_PERIOD_TABLE_NAME);
// rename the temporary table to the original table name
await queryRunner.query(
`ALTER TABLE ${tempInsightsByPeriodTable} RENAME TO ${insightsByPeriodTable};`,
);
} else if (isPostgres) {
await queryRunner.query(
`ALTER TABLE ${insightsRawTable} ALTER COLUMN ${valueColumnName} TYPE BIGINT;`,
);
await queryRunner.query(
`ALTER TABLE ${insightsByPeriodTable} ALTER COLUMN ${valueColumnName} TYPE BIGINT;`,
);
}
}
}
@@ -0,0 +1,116 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const table = {
sessions: 'chat_hub_sessions',
messages: 'chat_hub_messages',
user: 'user',
credentials: 'credentials_entity',
workflows: 'workflow_entity',
executions: 'execution_entity',
} as const;
export class CreateChatHubTables1760019379982 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(table.sessions)
.withColumns(
column('id').uuid.primary,
column('title').varchar(256).notNull,
column('ownerId').uuid.notNull,
column('lastMessageAt').timestampTimezone(),
column('credentialId').varchar(36),
column('provider')
.varchar(16)
.comment('ChatHubProvider enum: "openai", "anthropic", "google", "n8n"'),
column('model')
.varchar(64)
.comment('Model name used at the respective Model node, ie. "gpt-4"'),
column('workflowId').varchar(36),
)
.withForeignKey('ownerId', {
tableName: table.user,
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('credentialId', {
tableName: table.credentials,
columnName: 'id',
onDelete: 'SET NULL',
})
.withForeignKey('workflowId', {
tableName: table.workflows,
columnName: 'id',
onDelete: 'SET NULL',
}).withTimestamps;
await createTable(table.messages)
.withColumns(
column('id').uuid.primary.notNull,
column('sessionId').uuid.notNull,
column('previousMessageId').uuid,
column('revisionOfMessageId').uuid,
column('turnId').uuid,
column('retryOfMessageId').uuid,
column('type')
.varchar(16)
.notNull.comment('ChatHubMessageType enum: "human", "ai", "system", "tool", "generic"'),
column('name').varchar(128).notNull,
column('state')
.varchar(16)
.default("'active'")
.notNull.comment('ChatHubMessageState enum: "active", "superseded", "hidden", "deleted"'),
column('content').text.notNull,
column('provider')
.varchar(16)
.comment('ChatHubProvider enum: "openai", "anthropic", "google", "n8n"'),
column('model')
.varchar(64)
.comment('Model name used at the respective Model node, ie. "gpt-4"'),
column('workflowId').varchar(36),
column('runIndex')
.int.notNull.default(0)
.comment('The nth attempt this message has been generated/retried this turn'),
column('executionId').int,
)
.withForeignKey('sessionId', {
tableName: table.sessions,
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('previousMessageId', {
tableName: table.messages,
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('workflowId', {
tableName: table.workflows,
columnName: 'id',
onDelete: 'SET NULL',
})
.withForeignKey('turnId', {
tableName: table.messages,
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('retryOfMessageId', {
tableName: table.messages,
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('revisionOfMessageId', {
tableName: table.messages,
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('executionId', {
tableName: table.executions,
columnName: 'id',
onDelete: 'SET NULL',
}).withTimestamps;
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(table.messages);
await dropTable(table.sessions);
}
}
@@ -0,0 +1,63 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const table = {
agents: 'chat_hub_agents',
sessions: 'chat_hub_sessions',
messages: 'chat_hub_messages',
user: 'user',
credentials: 'credentials_entity',
} as const;
export class CreateChatHubAgentTable1760020000000 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, addColumns, column } }: MigrationContext) {
await createTable(table.agents)
.withColumns(
column('id').uuid.primary,
column('name').varchar(256).notNull,
column('description').varchar(512),
column('systemPrompt').text.notNull,
column('ownerId').uuid.notNull,
// Required for agents to work but can be deleted, so nullable
column('credentialId').varchar(36),
column('provider')
.varchar(16)
.comment('ChatHubProvider enum: "openai", "anthropic", "google", "n8n"').notNull,
column('model')
.varchar(64)
.comment('Model name used at the respective Model node, ie. "gpt-4"').notNull,
)
.withForeignKey('ownerId', {
tableName: table.user,
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('credentialId', {
tableName: table.credentials,
columnName: 'id',
onDelete: 'SET NULL',
}).withTimestamps;
// Add agentId and agentName to chat_hub_sessions
await addColumns(table.sessions, [
column('agentId')
.varchar(36)
.comment('ID of the custom agent (if provider is "custom-agent")'),
column('agentName')
.varchar(128)
.comment('Cached name of the custom agent (if provider is "custom-agent")'),
]);
// Add agentId to chat_hub_messages
await addColumns(table.messages, [
column('agentId')
.varchar(36)
.comment('ID of the custom agent (if provider is "custom-agent")'),
]);
}
async down({ schemaBuilder: { dropTable, dropColumns } }: MigrationContext) {
await dropColumns(table.messages, ['agentId']);
await dropColumns(table.sessions, ['agentId', 'agentName']);
await dropTable(table.agents);
}
}
@@ -0,0 +1,53 @@
import type { Role } from '../../entities';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class UniqueRoleNames1760020838000 implements ReversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('role');
const displayNameColumn = escape.columnName('displayName');
const slugColumn = escape.columnName('slug');
const createdAtColumn = escape.columnName('createdAt');
const allRoles: Array<Pick<Role, 'slug' | 'displayName'>> = await runQuery(
`SELECT ${slugColumn}, ${displayNameColumn} FROM ${tableName} ORDER BY ${displayNameColumn}, ${createdAtColumn} ASC`,
);
// Group roles by displayName in memory
const groupedByName = new Map<string, Array<Pick<Role, 'slug' | 'displayName'>>>();
for (const role of allRoles) {
const existing = groupedByName.get(role.displayName) || [];
existing.push(role);
groupedByName.set(role.displayName, existing);
}
for (const [_, roles] of groupedByName.entries()) {
if (roles.length > 1) {
const duplicates = roles.slice(1);
let index = 2;
for (const role of duplicates.values()) {
let newDisplayName = `${role.displayName} ${index}`;
while (allRoles.some((r) => r.displayName === newDisplayName)) {
index++;
newDisplayName = `${role.displayName} ${index}`;
}
await runQuery(
`UPDATE ${tableName} SET ${displayNameColumn} = :displayName WHERE ${slugColumn} = :slug`,
{
displayName: newDisplayName,
slug: role.slug,
},
);
index++;
}
}
}
const indexName = escape.indexName('UniqueRoleDisplayName');
await runQuery(`CREATE UNIQUE INDEX ${indexName} ON ${tableName} (${displayNameColumn})`);
}
async down({ escape, runQuery }: MigrationContext) {
const indexName = escape.indexName('UniqueRoleDisplayName');
await runQuery(`DROP INDEX ${indexName}`);
}
}
@@ -0,0 +1,108 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class CreateOAuthEntities1760116750277 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
// Create oauth_clients table
await createTable('oauth_clients').withColumns(
column('id').varchar().primary.notNull,
column('name').varchar(255).notNull,
column('redirectUris').json.notNull,
column('grantTypes').json.notNull,
column('clientSecret').varchar(255),
column('clientSecretExpiresAt').bigint,
column('tokenEndpointAuthMethod')
.varchar(255)
.notNull.default("'none'")
.comment('Possible values: none, client_secret_basic or client_secret_post'),
).withTimestamps;
// Create oauth_authorization_codes table
await createTable('oauth_authorization_codes')
.withColumns(
column('code').varchar(255).primary.notNull,
column('clientId').varchar().notNull,
column('userId').uuid.notNull,
column('redirectUri').varchar(255).notNull,
column('codeChallenge').varchar(255).notNull,
column('codeChallengeMethod').varchar(255).notNull,
column('expiresAt').bigint.notNull.comment('Unix timestamp in milliseconds'),
column('state').varchar(255), // Should be nullable
column('used').bool.notNull.default(false),
)
.withForeignKey('clientId', {
tableName: 'oauth_clients',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
// Create oauth_access_tokens table
await createTable('oauth_access_tokens')
.withColumns(
column('token').varchar().primary.notNull,
column('clientId').varchar().notNull,
column('userId').uuid.notNull,
)
.withForeignKey('clientId', {
tableName: 'oauth_clients',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'CASCADE',
});
// Create oauth_refresh_tokens table
await createTable('oauth_refresh_tokens')
.withColumns(
column('token').varchar(255).primary.notNull,
column('clientId').varchar().notNull,
column('userId').uuid.notNull,
column('expiresAt').bigint.notNull.comment('Unix timestamp in milliseconds'),
)
.withForeignKey('clientId', {
tableName: 'oauth_clients',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
// Create oauth_user_consents table
await createTable('oauth_user_consents')
.withColumns(
column('id').int.primary.autoGenerate2.notNull,
column('userId').uuid.notNull,
column('clientId').varchar().notNull,
column('grantedAt').bigint.notNull.comment('Unix timestamp in milliseconds'),
)
.withForeignKey('clientId', {
tableName: 'oauth_clients',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'CASCADE',
})
.withUniqueConstraintOn(['userId', 'clientId']);
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable('oauth_user_consents');
await dropTable('oauth_refresh_tokens');
await dropTable('oauth_access_tokens');
await dropTable('oauth_authorization_codes');
await dropTable('oauth_clients');
}
}
@@ -0,0 +1,36 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class CreateWorkflowDependencyTable1760314000000 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable('workflow_dependency')
.withColumns(
column('id').int.primary.autoGenerate2,
column('workflowId').varchar(36).notNull,
column('workflowVersionId').int.notNull.comment('Version of the workflow'),
column('dependencyType')
.varchar(32)
.notNull.comment(
'Type of dependency: "credential", "nodeType", "webhookPath", or "workflowCall"',
),
column('dependencyKey').varchar(255).notNull.comment('ID or name of the dependency'),
column('dependencyInfo')
.varchar(255)
.comment('Additional info about the dependency, interpreted based on type'),
column('indexVersionId')
.smallint.notNull.default(1)
.comment('Version of the index structure'),
)
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE',
})
.withIndexOn(['workflowId'])
.withIndexOn(['dependencyType'])
.withIndexOn(['dependencyKey']).withCreatedAt;
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable('workflow_dependency');
}
}
@@ -0,0 +1,36 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const table = {
messages: 'chat_hub_messages',
} as const;
export class DropUnusedChatHubColumns1760965142113 implements ReversibleMigration {
async up({ schemaBuilder: { dropColumns, addColumns, column } }: MigrationContext) {
await dropColumns(table.messages, ['turnId', 'runIndex', 'state']);
await addColumns(table.messages, [
column('status')
.varchar(16)
.default("'success'")
.notNull.comment(
'ChatHubMessageStatus enum, eg. "success", "error", "running", "cancelled"',
),
]);
}
async down({
schemaBuilder: { dropColumns, addColumns, column, addForeignKey },
}: MigrationContext) {
await dropColumns(table.messages, ['status']);
await addColumns(table.messages, [
column('turnId').uuid,
column('runIndex')
.int.notNull.default(0)
.comment('The nth attempt this message has been generated/retried this turn'),
column('state')
.varchar(16)
.default("'active'")
.notNull.comment('ChatHubMessageState enum: "active", "superseded", "hidden", "deleted"'),
]);
await addForeignKey(table.messages, 'turnId', [table.messages, 'id'], undefined, 'CASCADE');
}
}
@@ -0,0 +1,19 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const table = {
messages: 'chat_hub_messages',
} as const;
export class AddAttachmentsToChatHubMessages1761773155024 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns(table.messages, [
column('attachments').json.comment(
'File attachments for the message (if any), stored as JSON. Files are stored as base64-encoded data URLs.',
),
]);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns(table.messages, ['attachments']);
}
}
@@ -0,0 +1,26 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const table = {
sessions: 'chat_hub_sessions',
agents: 'chat_hub_agents',
} as const;
export class AddToolsColumnToChatHubTables1761830340990 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns(table.sessions, [
column('tools')
.json.notNull.default("'[]'")
.comment('Tools available to the agent as JSON node definitions'),
]);
await addColumns(table.agents, [
column('tools')
.json.notNull.default("'[]'")
.comment('Tools available to the agent as JSON node definitions'),
]);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns(table.sessions, ['tools']);
await dropColumns(table.agents, ['tools']);
}
}
@@ -0,0 +1,11 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddWorkflowDescriptionColumn1762177736257 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns('workflow_entity', [column('description').text]);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns('workflow_entity', ['description']);
}
}
@@ -0,0 +1,99 @@
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
export class BackfillMissingWorkflowHistoryRecords1762763704614 implements IrreversibleMigration {
/**
* 1. Generate/regenerate versionIds for workflows that need them:
* - NULL/empty versionId
* - duplicate versionIds that do not own the history record
* (i.e., no history record with matching versionId AND workflowId)
* 2. Create workflow_history records for all workflows missing them
* 3. Make versionId NOT NULL to ensure data consistency
*/
async up({ escape, runQuery, schemaBuilder }: MigrationContext) {
const workflowTable = escape.tableName('workflow_entity');
const historyTable = escape.tableName('workflow_history');
const versionIdColumn = escape.columnName('versionId');
const idColumn = escape.columnName('id');
const workflowIdColumn = escape.columnName('workflowId');
const nodesColumn = escape.columnName('nodes');
const connectionsColumn = escape.columnName('connections');
const authorsColumn = escape.columnName('authors');
const createdAtColumn = escape.columnName('createdAt');
const updatedAtColumn = escape.columnName('updatedAt');
// Step 1: Generate versionIds that do not exist in workflow history
const workflowsNeedingNewVersionId = await runQuery<Array<{ id: string }>>(`
-- Find duplicate versionIds (appear in more than one workflow)
WITH dup_version AS (
SELECT ${versionIdColumn}
FROM ${workflowTable}
WHERE ${versionIdColumn} IS NOT NULL AND ${versionIdColumn} <> ''
GROUP BY ${versionIdColumn}
HAVING COUNT(*) > 1
)
SELECT w.${idColumn} AS id
FROM ${workflowTable} w
LEFT JOIN ${historyTable} wh
ON wh.${versionIdColumn} = w.${versionIdColumn}
AND wh.${workflowIdColumn} = w.${idColumn}
LEFT JOIN dup_version d
ON d.${versionIdColumn} = w.${versionIdColumn}
WHERE
-- missing or empty versionId
w.${versionIdColumn} IS NULL OR w.${versionIdColumn} = ''
-- duplicate versionId without matching history entry by both versionId and workflowId
OR (
d.${versionIdColumn} IS NOT NULL
AND wh.${workflowIdColumn} IS NULL
);
`);
// Running in a loop to avoid using DB-specific syntax for generating UUIDs
for (const workflow of workflowsNeedingNewVersionId) {
const versionId = crypto.randomUUID();
await runQuery(
`
UPDATE ${workflowTable}
SET ${versionIdColumn} = :versionId
WHERE ${idColumn} = :id
`,
{ versionId, id: workflow.id },
);
}
// Step 2: Create workflow_history records for workflows missing them
await runQuery(
`
INSERT INTO ${historyTable} (
${versionIdColumn},
${workflowIdColumn},
${authorsColumn},
${nodesColumn},
${connectionsColumn},
${createdAtColumn},
${updatedAtColumn}
)
SELECT
w.${versionIdColumn},
w.${idColumn},
:authors,
w.${nodesColumn},
w.${connectionsColumn},
:createdAt,
:updatedAt
FROM ${workflowTable} w
LEFT JOIN ${historyTable} wh
ON w.${versionIdColumn} = wh.${versionIdColumn}
WHERE wh.${versionIdColumn} IS NULL
`,
{
authors: 'system migration',
createdAt: new Date(),
updatedAt: new Date(),
},
);
// Step 3: Make versionId NOT NULL
await schemaBuilder.addNotNull('workflow_entity', 'versionId');
}
}
@@ -0,0 +1,21 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddIsGlobalColumnToCredentialsTable1762771954619 implements ReversibleMigration {
async up({ escape, runQuery, isSqlite }: MigrationContext) {
const tableName = escape.tableName('credentials_entity');
const columnName = escape.columnName('isGlobal');
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('isGlobal');
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`);
}
}
@@ -0,0 +1,20 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const tableName = 'workflow_history';
const name = 'name';
const autosaved = 'autosaved';
const description = 'description';
export class AddWorkflowHistoryAutoSaveFields1762847206508 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns(tableName, [
column(name).varchar(128),
column(autosaved).bool.notNull.default(false),
column(description).text,
]);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns(tableName, [name, autosaved, description]);
}
}
@@ -0,0 +1,96 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const WORKFLOWS_TABLE_NAME = 'workflow_entity';
const WORKFLOW_HISTORY_TABLE_NAME = 'workflow_history';
export class AddActiveVersionIdColumn1763047800000 implements ReversibleMigration {
async up({
schemaBuilder: { addColumns, column, addForeignKey },
queryRunner,
escape,
runQuery,
}: MigrationContext) {
const workflowsTableName = escape.tableName(WORKFLOWS_TABLE_NAME);
await addColumns(WORKFLOWS_TABLE_NAME, [column('activeVersionId').varchar(36)]);
await addForeignKey(
WORKFLOWS_TABLE_NAME,
'activeVersionId',
[WORKFLOW_HISTORY_TABLE_NAME, 'versionId'],
undefined,
'RESTRICT',
);
// Fix for ADO-4517: some users pulled workflows to prod instances and ended up having missing records
// Run AFTER adding column/FK to avoid CASCADE DELETE
await this.backFillHistoryRecords(runQuery, escape);
// For existing ACTIVE workflows, set activeVersionId = versionId
const versionIdColumn = escape.columnName('versionId');
const activeColumn = escape.columnName('active');
const activeVersionIdColumn = escape.columnName('activeVersionId');
await queryRunner.query(
`UPDATE ${workflowsTableName}
SET ${activeVersionIdColumn} = ${versionIdColumn}
WHERE ${activeColumn} = true`,
);
}
async down({ schemaBuilder: { dropColumns, dropForeignKey } }: MigrationContext) {
await dropForeignKey(WORKFLOWS_TABLE_NAME, 'activeVersionId', [
WORKFLOW_HISTORY_TABLE_NAME,
'versionId',
]);
await dropColumns(WORKFLOWS_TABLE_NAME, ['activeVersionId']);
}
// Create workflow_history records for workflows missing them
async backFillHistoryRecords(
runQuery: MigrationContext['runQuery'],
escape: MigrationContext['escape'],
) {
const workflowTable = escape.tableName('workflow_entity');
const historyTable = escape.tableName('workflow_history');
const versionIdColumn = escape.columnName('versionId');
const idColumn = escape.columnName('id');
const workflowIdColumn = escape.columnName('workflowId');
const nodesColumn = escape.columnName('nodes');
const connectionsColumn = escape.columnName('connections');
const authorsColumn = escape.columnName('authors');
const createdAtColumn = escape.columnName('createdAt');
const updatedAtColumn = escape.columnName('updatedAt');
await runQuery(
`
INSERT INTO ${historyTable} (
${versionIdColumn},
${workflowIdColumn},
${authorsColumn},
${nodesColumn},
${connectionsColumn},
${createdAtColumn},
${updatedAtColumn}
)
SELECT
w.${versionIdColumn},
w.${idColumn},
:authors,
w.${nodesColumn},
w.${connectionsColumn},
:createdAt,
:updatedAt
FROM ${workflowTable} w
LEFT JOIN ${historyTable} wh
ON w.${versionIdColumn} = wh.${versionIdColumn}
WHERE wh.${versionIdColumn} IS NULL
`,
{
authors: 'system migration',
createdAt: new Date(),
updatedAt: new Date(),
},
);
}
}
@@ -0,0 +1,222 @@
import { ERROR_TRIGGER_NODE_TYPE, EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE } from 'n8n-workflow';
import { randomUUID } from 'node:crypto';
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
type Node = {
type: string;
disabled?: boolean;
parameters?: Record<string, unknown>;
};
type Workflow = {
id: string;
active: boolean;
versionId: string;
activeVersionId: string | null;
nodes: string | Node[];
connections: string;
};
/**
* Activates all workflows that contain an executeWorkflowTrigger node with at least one parameter,
* or an errorTrigger node. Also disables any other trigger nodes within those workflows.
*/
export class ActivateExecuteWorkflowTriggerWorkflows1763048000000 implements IrreversibleMigration {
private findExecuteWfAndErrorTriggers(nodes: Node[]): {
executeWorkflowTriggerNode: Node | undefined;
errorTriggerNode: Node | undefined;
} {
let executeWorkflowTriggerNode: Node | undefined;
let errorTriggerNode: Node | undefined;
for (const node of nodes) {
if (node.type === EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE) {
executeWorkflowTriggerNode = node;
} else if (node.type === ERROR_TRIGGER_NODE_TYPE) {
errorTriggerNode = node;
}
// Early exit if both are found
if (executeWorkflowTriggerNode && errorTriggerNode) {
break;
}
}
return { executeWorkflowTriggerNode, errorTriggerNode };
}
async up({
escape,
runQuery,
runInBatches,
parseJson,
isPostgres,
logger,
migrationName,
}: MigrationContext) {
const tableName = escape.tableName('workflow_entity');
const historyTableName = escape.tableName('workflow_history');
const idColumn = escape.columnName('id');
const versionIdColumn = escape.columnName('versionId');
const nodesColumn = escape.columnName('nodes');
const connectionsColumn = escape.columnName('connections');
const activeColumn = escape.columnName('active');
const activeVersionIdColumn = escape.columnName('activeVersionId');
const workflowIdColumn = escape.columnName('workflowId');
const authorsColumn = escape.columnName('authors');
const createdAtColumn = escape.columnName('createdAt');
const updatedAtColumn = escape.columnName('updatedAt');
const nodesColumnForLike = isPostgres ? `${nodesColumn}::text` : nodesColumn;
const inactiveWorkflows = `SELECT ${idColumn}, ${nodesColumn}, ${connectionsColumn}, ${versionIdColumn}, ${activeVersionIdColumn} FROM ${tableName} WHERE ${activeColumn} = false AND (${nodesColumnForLike} LIKE '%n8n-nodes-base.executeWorkflowTrigger%' OR ${nodesColumnForLike} LIKE '%n8n-nodes-base.errorTrigger%')`;
await runInBatches<Workflow>(inactiveWorkflows, async (workflows) => {
for (const workflow of workflows) {
let nodes: Node[];
try {
nodes = parseJson(workflow.nodes);
} catch (error) {
logger.warn(
`[${migrationName}] Failed to parse nodes for workflow ${workflow.id}: ${error instanceof Error ? error.message : 'Unknown error'}. Skipping this workflow.`,
);
continue;
}
const { executeWorkflowTriggerNode, errorTriggerNode } =
this.findExecuteWfAndErrorTriggers(nodes);
if (!executeWorkflowTriggerNode && !errorTriggerNode) {
continue;
}
// Skip if both trigger nodes are disabled
const executeWorkflowTriggerDisabled = executeWorkflowTriggerNode?.disabled === true;
const errorTriggerDisabled = errorTriggerNode?.disabled === true;
if (
(!executeWorkflowTriggerNode || executeWorkflowTriggerDisabled) &&
(!errorTriggerNode || errorTriggerDisabled)
) {
continue;
}
let hasValidExecuteWorkflowTrigger = false;
if (executeWorkflowTriggerNode && !executeWorkflowTriggerDisabled) {
const inputSource = executeWorkflowTriggerNode.parameters?.inputSource;
const shouldActivateByInputSource =
inputSource === 'passthrough' || inputSource === 'jsonExample';
// For nodes without inputSource (version 1 or legacy version 1.1)
let hasLegacyParametersOrIsVersion1 = false;
if (!inputSource) {
const params = executeWorkflowTriggerNode.parameters;
// Version 1 nodes have no parameters at all - they should be activated
if (!params || Object.keys(params).length === 0) {
hasLegacyParametersOrIsVersion1 = true;
} else {
// Version 1.1 legacy: check if they have valid workflowInputs
const workflowInputs = params.workflowInputs;
hasLegacyParametersOrIsVersion1 = Boolean(
workflowInputs &&
typeof workflowInputs === 'object' &&
'values' in workflowInputs &&
Array.isArray(workflowInputs.values) &&
workflowInputs.values.length > 0 &&
this.hasValidWorkflowInputs(workflowInputs.values),
);
}
}
hasValidExecuteWorkflowTrigger =
shouldActivateByInputSource || hasLegacyParametersOrIsVersion1;
if (!hasValidExecuteWorkflowTrigger && !errorTriggerNode) {
continue;
}
}
// Disable other trigger nodes (keep valid executeWorkflowTrigger and errorTrigger enabled)
let nodesModified = false;
nodes.forEach((node: Node) => {
if (node.type && this.isTriggerNode(node.type)) {
// Keep valid Execute Workflow Trigger active
if (
node.type === EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE &&
hasValidExecuteWorkflowTrigger
) {
return;
}
// Keep Error Trigger active
if (node.type === ERROR_TRIGGER_NODE_TYPE) {
return;
}
// Disable all other triggers (including invalid Execute Workflow Trigger)
if (!node.disabled) {
node.disabled = true;
nodesModified = true;
}
}
});
if (nodesModified) {
const newVersionId = randomUUID();
// Create workflow_history record with the modified nodes
await runQuery(
`INSERT INTO ${historyTableName} (${versionIdColumn}, ${workflowIdColumn}, ${authorsColumn}, ${nodesColumn}, ${connectionsColumn}, ${createdAtColumn}, ${updatedAtColumn}) VALUES (:versionId, :workflowId, :authors, :nodes, :connections, :createdAt, :updatedAt)`,
{
versionId: newVersionId,
workflowId: workflow.id,
authors: 'system migration',
nodes: JSON.stringify(nodes),
connections: workflow.connections,
createdAt: new Date(),
updatedAt: new Date(),
},
);
// Update workflow_entity with new versionId, modified nodes, and set as active
await runQuery(
`UPDATE ${tableName} SET ${activeColumn} = :active, ${nodesColumn} = :nodes, ${versionIdColumn} = :versionId, ${activeVersionIdColumn} = :activeVersionId WHERE ${idColumn} = :id`,
{
active: true,
nodes: JSON.stringify(nodes),
versionId: newVersionId,
activeVersionId: newVersionId,
id: workflow.id,
},
);
} else {
// No nodes modified, just activate with existing versionId
await runQuery(
`UPDATE ${tableName} SET ${activeColumn} = :active, ${activeVersionIdColumn} = :versionId WHERE ${idColumn} = :id`,
{
active: true,
versionId: workflow.versionId,
id: workflow.id,
},
);
}
}
});
}
private isTriggerNode(nodeType: string): boolean {
return nodeType.includes('Trigger');
}
private hasValidWorkflowInputs(values: unknown[]): boolean {
return values.every(
(value: unknown) =>
value &&
typeof value === 'object' &&
'name' in value &&
typeof value.name === 'string' &&
value.name.length > 0 &&
// type is optional (defaults to 'string' in version 1.1)
(!('type' in value) || (typeof value.type === 'string' && value.type.length > 0)),
);
}
}
@@ -0,0 +1,58 @@
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
const TABLE_NAME = 'oauth_authorization_codes';
const TEMP_TABLE_NAME = 'temp_oauth_authorization_codes';
export class ChangeOAuthStateColumnToUnboundedVarchar1763572724000
implements IrreversibleMigration
{
async up({
isSqlite,
isPostgres,
escape,
copyTable,
queryRunner,
schemaBuilder: { createTable, column, dropTable },
}: MigrationContext) {
const tableName = escape.tableName(TABLE_NAME);
if (isSqlite) {
const tempTableName = escape.tableName(TEMP_TABLE_NAME);
await createTable(TEMP_TABLE_NAME)
.withColumns(
column('code').varchar(255).primary.notNull,
column('clientId').varchar().notNull,
column('userId').uuid.notNull,
column('redirectUri').varchar().notNull,
column('codeChallenge').varchar().notNull,
column('codeChallengeMethod').varchar(255).notNull,
column('expiresAt').bigint.notNull.comment('Unix timestamp in milliseconds'),
column('state').varchar(),
column('used').bool.notNull.default(false),
)
.withForeignKey('clientId', {
tableName: 'oauth_clients',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
await copyTable(TABLE_NAME, TEMP_TABLE_NAME);
await dropTable(TABLE_NAME);
await queryRunner.query(`ALTER TABLE ${tempTableName} RENAME TO ${tableName};`);
} else if (isPostgres) {
await queryRunner.query(
`ALTER TABLE ${tableName} ALTER COLUMN ${escape.columnName('state')} TYPE VARCHAR,` +
` ALTER COLUMN ${escape.columnName('codeChallenge')} TYPE VARCHAR,` +
` ALTER COLUMN ${escape.columnName('redirectUri')} TYPE VARCHAR;`,
);
}
}
}
@@ -0,0 +1,26 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const tableName = 'binary_data';
export class CreateBinaryDataTable1763716655000 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(tableName)
.withColumns(
column('fileId').uuid.primary.notNull,
column('sourceType')
.varchar(50)
.notNull.comment("Source the file belongs to, e.g. 'execution'"),
column('sourceId').varchar(255).notNull.comment('ID of the source, e.g. execution ID'),
column('data').binary.notNull.comment('Raw, not base64 encoded'),
column('mimeType').varchar(255),
column('fileName').varchar(255),
column('fileSize').int.notNull.comment('In bytes'),
)
.withEnumCheck('sourceType', ['execution', 'chat_message_attachment'])
.withIndexOn(['sourceType', 'sourceId']).withTimestamps;
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(tableName);
}
}
@@ -0,0 +1,58 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const workflowPublishHistoryTableName = 'workflow_publish_history';
export class CreateWorkflowPublishHistoryTable1764167920585 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column }, escape, runQuery }: MigrationContext) {
await createTable(workflowPublishHistoryTableName)
.withColumns(
column('id').int.primary.autoGenerate2,
column('workflowId').varchar(36).notNull,
column('versionId').varchar(36).notNull,
column('event')
.varchar(36)
.notNull.comment(
'Type of history record: activated (workflow is now active), deactivated (workflow is now inactive)',
),
column('userId').uuid,
)
.withCreatedAt.withIndexOn(['workflowId', 'versionId'])
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('versionId', {
tableName: 'workflow_history',
columnName: 'versionId',
onDelete: 'CASCADE',
})
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'SET NULL',
})
.withEnumCheck('event', ['activated', 'deactivated']);
const escapedWphTableName = escape.tableName(workflowPublishHistoryTableName);
const workflowEntityTableName = escape.tableName('workflow_entity');
const id = escape.columnName('id');
const activeVersionId = escape.columnName('activeVersionId');
const workflowId = escape.columnName('workflowId');
const versionId = escape.columnName('versionId');
const event = escape.columnName('event');
const updatedAt = escape.columnName('updatedAt');
const createdAt = escape.columnName('createdAt');
await runQuery(
`INSERT INTO ${escapedWphTableName} (${workflowId}, ${versionId}, ${event}, ${createdAt})
SELECT we.${id}, we.${activeVersionId}, 'activated', we.${updatedAt}
FROM ${workflowEntityTableName} we
WHERE we.${activeVersionId} IS NOT NULL`,
);
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(workflowPublishHistoryTableName);
}
}
@@ -0,0 +1,42 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const table = {
project: 'project',
projectRelation: 'project_relation',
} as const;
const FOREIGN_KEY_NAME = 'projects_creatorId_foreign';
export class AddCreatorIdToProjectTable1764276827837 implements ReversibleMigration {
async up({
escape,
schemaBuilder: { addColumns, addForeignKey, column },
queryRunner,
}: MigrationContext) {
await addColumns(table.project, [
column('creatorId').uuid.comment('ID of the user who created the project'),
]);
await addForeignKey(table.project, 'creatorId', ['user', 'id'], FOREIGN_KEY_NAME, 'SET NULL');
// Populate creatorId for existing personal projects.
// We can only do this for personal projects as for team projects
// we don't have a reliable way of knowing who the creator was.
await queryRunner.query(`
UPDATE ${escape.tableName(table.project)} AS project
SET ${escape.columnName('creatorId')} = (
SELECT pr.${escape.columnName('userId')}
FROM ${escape.tableName(table.projectRelation)} AS pr
WHERE pr.${escape.columnName('projectId')} = project.${escape.columnName('id')}
AND pr.${escape.columnName('role')} = 'project:personalOwner'
LIMIT 1
)
WHERE project.${escape.columnName('type')} = 'personal'
AND project.${escape.columnName('creatorId')} IS NULL;`);
}
async down({ schemaBuilder: { dropColumns, dropForeignKey } }: MigrationContext) {
await dropForeignKey(table.project, 'creatorId', ['user', 'id'], FOREIGN_KEY_NAME);
await dropColumns(table.project, ['creatorId']);
}
}
@@ -0,0 +1,22 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const tableName = 'dynamic_credential_resolver';
export class CreateDynamicCredentialResolverTable1764682447000 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(tableName)
.withColumns(
column('id').varchar(16).primary,
column('name').varchar(128).notNull,
column('type').varchar(128).notNull,
column('config').text.notNull.comment(
'Encrypted resolver configuration (JSON encrypted as string)',
),
)
.withTimestamps.withIndexOn('type');
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(tableName);
}
}
@@ -0,0 +1,31 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const tableName = 'dynamic_credential_entry';
export class AddDynamicCredentialEntryTable1764689388394 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(tableName)
.withColumns(
column('credential_id').varchar(16).primary.notNull,
column('subject_id').varchar(16).primary.notNull,
column('resolver_id').varchar(16).primary.notNull,
column('data').text.notNull,
)
.withTimestamps.withForeignKey('credential_id', {
tableName: 'credentials_entity',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('resolver_id', {
tableName: 'dynamic_credential_resolver',
columnName: 'id',
onDelete: 'CASCADE',
})
.withIndexOn(['subject_id'])
.withIndexOn(['resolver_id']);
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(tableName);
}
}
@@ -0,0 +1,48 @@
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
// Some users still have missing workflow history records after pulling workflows using source control feature
export class BackfillMissingWorkflowHistoryRecords1765448186933 implements IrreversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const workflowTable = escape.tableName('workflow_entity');
const historyTable = escape.tableName('workflow_history');
const versionIdColumn = escape.columnName('versionId');
const idColumn = escape.columnName('id');
const workflowIdColumn = escape.columnName('workflowId');
const nodesColumn = escape.columnName('nodes');
const connectionsColumn = escape.columnName('connections');
const authorsColumn = escape.columnName('authors');
const createdAtColumn = escape.columnName('createdAt');
const updatedAtColumn = escape.columnName('updatedAt');
await runQuery(
`
INSERT INTO ${historyTable} (
${versionIdColumn},
${workflowIdColumn},
${authorsColumn},
${nodesColumn},
${connectionsColumn},
${createdAtColumn},
${updatedAtColumn}
)
SELECT
w.${versionIdColumn},
w.${idColumn},
:authors,
w.${nodesColumn},
w.${connectionsColumn},
:createdAt,
:updatedAt
FROM ${workflowTable} w
LEFT JOIN ${historyTable} wh
ON w.${versionIdColumn} = wh.${versionIdColumn}
WHERE wh.${versionIdColumn} IS NULL
`,
{
authors: 'system migration',
createdAt: new Date(),
updatedAt: new Date(),
},
);
}
}
@@ -0,0 +1,38 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const credentialsTableName = 'credentials_entity';
const resolverTableName = 'dynamic_credential_resolver';
const FOREIGN_KEY_NAME = 'credentials_entity_resolverId_foreign';
export class AddResolvableFieldsToCredentials1765459448000 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, addForeignKey, column } }: MigrationContext) {
await addColumns(credentialsTableName, [
column('isResolvable').bool.notNull.default(false),
column('resolvableAllowFallback').bool.notNull.default(false),
column('resolverId').varchar(16),
]);
await addForeignKey(
credentialsTableName,
'resolverId',
[resolverTableName, 'id'],
FOREIGN_KEY_NAME,
'SET NULL',
);
}
async down({ schemaBuilder: { dropColumns, dropForeignKey } }: MigrationContext) {
await dropForeignKey(
credentialsTableName,
'resolverId',
[resolverTableName, 'id'],
FOREIGN_KEY_NAME,
);
await dropColumns(credentialsTableName, [
'isResolvable',
'resolvableAllowFallback',
'resolverId',
]);
}
}
@@ -0,0 +1,15 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const table = 'chat_hub_agents';
export class AddIconToAgentTable1765788427674 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
// Add icon column to agents table (nullable)
await addColumns(table, [column('icon').json]);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
// Drop icon column
await dropColumns(table, ['icon']);
}
}
@@ -0,0 +1,52 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const table = {
agents: 'chat_hub_agents',
sessions: 'chat_hub_sessions',
messages: 'chat_hub_messages',
} as const;
export class AddAgentIdForeignKeys1765886667897 implements ReversibleMigration {
async up({ schemaBuilder: { addForeignKey }, runQuery, escape }: MigrationContext) {
const escapedAgentIdColumn = escape.columnName('agentId');
// Clean up orphaned agentId references before adding foreign key constraint
await runQuery(
`UPDATE ${escape.tableName(table.sessions)} SET ${escapedAgentIdColumn} = NULL WHERE ${escapedAgentIdColumn} IS NOT NULL AND ${escapedAgentIdColumn} NOT IN (SELECT id FROM ${escape.tableName(table.agents)})`,
);
await runQuery(
`UPDATE ${escape.tableName(table.messages)} SET ${escapedAgentIdColumn} = NULL WHERE ${escapedAgentIdColumn} IS NOT NULL AND ${escapedAgentIdColumn} NOT IN (SELECT id FROM ${escape.tableName(table.agents)})`,
);
// Add foreign key constraint for agentId in sessions table
await addForeignKey(
table.sessions,
'agentId',
[table.agents, 'id'],
'FK_chat_hub_sessions_agentId',
'SET NULL',
);
await addForeignKey(
table.messages,
'agentId',
[table.agents, 'id'],
'FK_chat_hub_messages_agentId',
'SET NULL',
);
}
async down({ schemaBuilder: { dropForeignKey } }: MigrationContext) {
await dropForeignKey(
table.messages,
'agentId',
[table.agents, 'id'],
'FK_chat_hub_messages_agentId',
);
await dropForeignKey(
table.sessions,
'agentId',
[table.agents, 'id'],
'FK_chat_hub_sessions_agentId',
);
}
}
@@ -0,0 +1,15 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddWorkflowVersionIdToExecutionData1765892199653 implements ReversibleMigration {
async up({ runQuery, escape }: MigrationContext) {
const tableName = escape.tableName('execution_data');
const workflowVersionId = escape.columnName('workflowVersionId');
await runQuery(`ALTER TABLE ${tableName} ADD COLUMN ${workflowVersionId} VARCHAR(36)`);
}
async down({ runQuery, escape }: MigrationContext) {
const tableName = escape.tableName('execution_data');
const workflowVersionId = escape.columnName('workflowVersionId');
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${workflowVersionId}`);
}
}
@@ -0,0 +1,82 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
/**
* Adds workflow:publish scope to all existing project roles that have workflow:update.
*
* This migration ensures backward compatibility after the introduction of the workflow:publish project scope.
* Previously, only workflow:update was required for publishing workflows.
* Now, a dedicated workflow:publish scope is required instead.
*
* This migration:
* 1. Ensures the workflow:publish scope exists in the scope table
* 2. Finds all project roles with workflow:update scope and grants workflow:publish to them
*
* Both system roles (managed by code) and custom roles (user-created) are updated.
*/
export class AddWorkflowPublishScopeToProjectRoles1766064542000 implements ReversibleMigration {
async up({ escape, runQuery, logger }: MigrationContext) {
const scopeTableName = escape.tableName('scope');
const scopeSlugColumn = escape.columnName('slug');
const displayNameColumn = escape.columnName('displayName');
const descriptionColumn = escape.columnName('description');
const roleTableName = escape.tableName('role');
const roleScopeTableName = escape.tableName('role_scope');
const roleSlugColumn = escape.columnName('slug');
const roleTypeColumn = escape.columnName('roleType');
const roleScopeRoleSlugColumn = escape.columnName('roleSlug');
const roleScopeScopeSlugColumn = escape.columnName('scopeSlug');
// Step 1: Ensure workflow:publish scope exists
const insertScopeQuery = `INSERT INTO ${scopeTableName} (${scopeSlugColumn}, ${displayNameColumn}, ${descriptionColumn})
VALUES (:slug, :displayName, :description)
ON CONFLICT (${scopeSlugColumn}) DO NOTHING`;
await runQuery(insertScopeQuery, {
slug: 'workflow:publish',
displayName: 'Publish Workflow',
description: 'Allows publishing and unpublishing workflows.',
});
logger.debug('Ensured workflow:publish scope exists');
// Step 2: Add workflow:publish to eligible project roles (batch operation)
const batchInsertQuery = `
INSERT INTO ${roleScopeTableName} (${roleScopeRoleSlugColumn}, ${roleScopeScopeSlugColumn})
SELECT DISTINCT role.${roleSlugColumn}, :publishScope
FROM ${roleTableName} role
INNER JOIN ${roleScopeTableName} role_scope
ON role.${roleSlugColumn} = role_scope.${roleScopeRoleSlugColumn}
WHERE role.${roleTypeColumn} = :roleType
AND role_scope.${roleScopeScopeSlugColumn} = :updateScope
ON CONFLICT (${roleScopeRoleSlugColumn}, ${roleScopeScopeSlugColumn}) DO NOTHING
`;
await runQuery(batchInsertQuery, {
roleType: 'project',
updateScope: 'workflow:update',
publishScope: 'workflow:publish',
});
logger.info('Added workflow:publish scope to project roles with workflow:update');
}
async down({ escape, runQuery, logger }: MigrationContext) {
const roleScopeTableName = escape.tableName('role_scope');
const roleScopeScopeSlugColumn = escape.columnName('scopeSlug');
// Remove all workflow:publish scopes from all roles
// Since the up migration only adds workflow:publish to roles with workflow:update,
// all workflow:publish scopes can be safely removed to revert the migration.
const deleteQuery = `
DELETE FROM ${roleScopeTableName}
WHERE ${roleScopeScopeSlugColumn} = :publishScope
`;
await runQuery(deleteQuery, {
publishScope: 'workflow:publish',
});
logger.info('Removed workflow:publish scope from all roles');
}
}
@@ -0,0 +1,44 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddChatMessageIndices1766068346315 implements ReversibleMigration {
async up({ schemaBuilder: { addNotNull }, runQuery, escape }: MigrationContext) {
const sessionsTable = escape.tableName('chat_hub_sessions');
const idColumn = escape.columnName('id');
const createdAtColumn = escape.columnName('createdAt');
const ownerIdColumn = escape.columnName('ownerId');
const lastMessageAtColumn = escape.columnName('lastMessageAt');
const messagesTable = escape.tableName('chat_hub_messages');
const sessionIdColumn = escape.columnName('sessionId');
// Backfill lastMessageAt for existing rows to allow adding a NOT NULL constraint
await runQuery(
`UPDATE ${sessionsTable}
SET ${lastMessageAtColumn} = ${createdAtColumn}
WHERE ${lastMessageAtColumn} IS NULL`,
);
await addNotNull('chat_hub_sessions', 'lastMessageAt');
// Index intended for faster sessionRepository.getManyByUserId queries
await runQuery(
`CREATE INDEX IF NOT EXISTS ${escape.indexName('chat_hub_sessions_owner_lastmsg_id')}
ON ${sessionsTable}(${ownerIdColumn}, ${lastMessageAtColumn} DESC, ${idColumn})`,
);
// Index intended for faster sessionRepository.getOneByIdAndUserId queries and joins
await runQuery(
`CREATE INDEX IF NOT EXISTS ${escape.indexName('chat_hub_messages_sessionId')}
ON ${messagesTable}(${sessionIdColumn})`,
);
}
async down({ schemaBuilder: { dropNotNull }, runQuery, escape }: MigrationContext) {
await runQuery(
`DROP INDEX IF EXISTS ${escape.indexName('chat_hub_sessions_owner_lastmsg_id')}`,
);
await runQuery(`DROP INDEX IF EXISTS ${escape.indexName('chat_hub_messages_sessionId')}`);
await dropNotNull('chat_hub_sessions', 'lastMessageAt');
}
}
@@ -0,0 +1,58 @@
import type { ReversibleMigration, MigrationContext } from '../migration-types';
export class ExpandModelColumnLength1768402473068 implements ReversibleMigration {
async up({ isSqlite, isPostgres, escape, queryRunner }: MigrationContext) {
const messagesTable = escape.tableName('chat_hub_messages');
const sessionsTable = escape.tableName('chat_hub_sessions');
const modelColumn = escape.columnName('model');
if (isPostgres) {
await queryRunner.query(
`ALTER TABLE ${messagesTable} ALTER COLUMN ${modelColumn} TYPE VARCHAR(256);`,
);
await queryRunner.query(
`ALTER TABLE ${sessionsTable} ALTER COLUMN ${modelColumn} TYPE VARCHAR(256);`,
);
} else if (isSqlite) {
for (const table of [messagesTable, sessionsTable]) {
await queryRunner.query(`ALTER TABLE ${table} ADD COLUMN "model_tmp" VARCHAR(256);`);
await queryRunner.query(`UPDATE ${table} SET "model_tmp" = ${modelColumn};`);
await queryRunner.query(`ALTER TABLE ${table} DROP COLUMN ${modelColumn};`);
await queryRunner.query(`ALTER TABLE ${table} ADD COLUMN ${modelColumn} VARCHAR(256);`);
await queryRunner.query(`UPDATE ${table} SET ${modelColumn} = "model_tmp";`);
await queryRunner.query(`ALTER TABLE ${table} DROP COLUMN "model_tmp";`);
}
}
}
async down({ isSqlite, isPostgres, escape, queryRunner }: MigrationContext) {
const messagesTable = escape.tableName('chat_hub_messages');
const sessionsTable = escape.tableName('chat_hub_sessions');
const modelColumn = escape.columnName('model');
// Truncate values longer than 64 chars before changing type
if (isPostgres) {
await queryRunner.query(
`UPDATE ${messagesTable} SET ${modelColumn} = LEFT(${modelColumn}, 64) WHERE LENGTH(${modelColumn}) > 64;`,
);
await queryRunner.query(
`UPDATE ${sessionsTable} SET ${modelColumn} = LEFT(${modelColumn}, 64) WHERE LENGTH(${modelColumn}) > 64;`,
);
await queryRunner.query(
`ALTER TABLE ${messagesTable} ALTER COLUMN ${modelColumn} TYPE VARCHAR(64);`,
);
await queryRunner.query(
`ALTER TABLE ${sessionsTable} ALTER COLUMN ${modelColumn} TYPE VARCHAR(64);`,
);
} else if (isSqlite) {
for (const table of [messagesTable, sessionsTable]) {
await queryRunner.query(`ALTER TABLE ${table} ADD COLUMN "model_tmp" VARCHAR(64);`);
await queryRunner.query(`UPDATE ${table} SET "model_tmp" = SUBSTR(${modelColumn}, 1, 64);`);
await queryRunner.query(`ALTER TABLE ${table} DROP COLUMN ${modelColumn};`);
await queryRunner.query(`ALTER TABLE ${table} ADD COLUMN ${modelColumn} VARCHAR(64);`);
await queryRunner.query(`UPDATE ${table} SET ${modelColumn} = "model_tmp";`);
await queryRunner.query(`ALTER TABLE ${table} DROP COLUMN "model_tmp";`);
}
}
}
}
@@ -0,0 +1,19 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddStoredAtToExecutionEntity1768557000000 implements ReversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const executionEntity = escape.tableName('execution_entity');
const storedAt = escape.columnName('storedAt');
await runQuery(
`ALTER TABLE ${executionEntity} ADD COLUMN ${storedAt} VARCHAR(2) NOT NULL DEFAULT 'db' CHECK(${storedAt} IN ('db', 'fs', 's3'))`,
);
}
async down({ escape, runQuery }: MigrationContext) {
const executionEntity = escape.tableName('execution_entity');
const storedAt = escape.columnName('storedAt');
await runQuery(`ALTER TABLE ${executionEntity} DROP COLUMN ${storedAt}`);
}
}
@@ -0,0 +1,36 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const tableName = 'dynamic_credential_user_entry';
export class AddDynamicCredentialUserEntryTable1768901721000 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(tableName)
.withColumns(
column('credentialId').varchar(16).primary.notNull,
column('userId').uuid.primary.notNull,
column('resolverId').varchar(16).primary.notNull,
column('data').text.notNull,
)
.withTimestamps.withForeignKey('credentialId', {
tableName: 'credentials_entity',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('resolverId', {
tableName: 'dynamic_credential_resolver',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'CASCADE',
})
.withIndexOn(['userId'])
.withIndexOn(['resolverId']);
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(tableName);
}
}
@@ -0,0 +1,13 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddPublishedVersionIdToWorkflowDependency1769000000000 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column, createIndex } }: MigrationContext) {
await addColumns('workflow_dependency', [column('publishedVersionId').varchar(36)]);
await createIndex('workflow_dependency', ['publishedVersionId']);
}
async down({ schemaBuilder: { dropColumns, dropIndex } }: MigrationContext) {
await dropIndex('workflow_dependency', ['publishedVersionId']);
await dropColumns('workflow_dependency', ['publishedVersionId']);
}
}
@@ -0,0 +1,46 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const secretsProviderConnectionTable = 'secrets_provider_connection';
const projectSecretsProviderAccessTable = 'project_secrets_provider_access';
export class CreateSecretsProviderConnectionTables1769433700000 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column, createIndex } }: MigrationContext) {
// Create secrets_provider_connection table first (parent table)
await createTable(secretsProviderConnectionTable).withColumns(
column('id').int.primary.autoGenerate2,
column('providerKey').varchar(128).notNull,
column('type')
.varchar(36)
.notNull.comment(
'Type of secrets provider. Possible values: awsSecretsManager, gcpSecretsManager, vault, azureKeyVault, infisical',
),
column('encryptedSettings').text.notNull,
column('isEnabled').bool.default(false).notNull,
).withTimestamps;
await createIndex(secretsProviderConnectionTable, ['providerKey'], true);
// Create project_secrets_provider_access table (join table with FKs)
await createTable(projectSecretsProviderAccessTable)
.withColumns(
column('secretsProviderConnectionId').int.primary.notNull,
column('projectId').varchar(36).primary.notNull,
)
.withTimestamps.withForeignKey('secretsProviderConnectionId', {
tableName: secretsProviderConnectionTable,
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('projectId', {
tableName: 'project',
columnName: 'id',
onDelete: 'CASCADE',
});
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
// Drop in reverse order (child table first)
await dropTable(projectSecretsProviderAccessTable);
await dropTable(secretsProviderConnectionTable);
}
}
@@ -0,0 +1,32 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
/**
* Creates the workflow_published_version table to track the actually-published
* workflow version in production. This is distinct from workflow_entity.activeVersionId
* which tracks the requested/intended published version. Since publication is an
* asynchronous process, this table is updated only when the new version is fully
* deployed and ready for production executions.
*/
export class CreateWorkflowPublishedVersionTable1769698710000 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable('workflow_published_version')
.withColumns(
column('workflowId').varchar(36).primary.notNull,
column('publishedVersionId').varchar(36).notNull,
)
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('publishedVersionId', {
tableName: 'workflow_history',
columnName: 'versionId',
onDelete: 'CASCADE',
}).withTimestamps;
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable('workflow_published_version');
}
}
@@ -0,0 +1,50 @@
import type { MigrationContext, IrreversibleMigration } from '../migration-types';
const tmpTableName = 'tmp_dynamic_credential_entry';
const tableName = 'dynamic_credential_entry';
export class ExpandSubjectIDColumnLength1769784356000 implements IrreversibleMigration {
async up({
copyTable,
escape,
queryRunner,
schemaBuilder: { createTable, column, dropTable },
}: MigrationContext) {
// The subject_id is part of a composite primary key, so we cannot simply change the column type.
// Instead, we create a new temporary table with the updated column type, copy the data over,
// drop the old table, and rename the temporary table to the original name.
// At the time of the writing of this migration, there is no table using this table in a foreign key constraint.
// Thus it is safe to drop and recreate it.
const escapedTableName = escape.tableName(tableName);
const escapedTmpTableName = escape.tableName(tmpTableName);
// createTable handles escaping and prefixing
await createTable(tmpTableName)
.withColumns(
column('credential_id').varchar(16).primary.notNull,
column('subject_id').varchar(2048).primary.notNull,
column('resolver_id').varchar(16).primary.notNull,
column('data').text.notNull,
)
.withTimestamps.withForeignKey('credential_id', {
tableName: 'credentials_entity',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('resolver_id', {
tableName: 'dynamic_credential_resolver',
columnName: 'id',
onDelete: 'CASCADE',
})
.withIndexOn(['subject_id'])
.withIndexOn(['resolver_id']);
// copyTable handles escaping and prefixing
await copyTable(tableName, tmpTableName);
await dropTable(tableName);
await queryRunner.query(`ALTER TABLE ${escapedTmpTableName} RENAME TO ${escapedTableName};`);
}
}
@@ -0,0 +1,91 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const PERSONAL_OWNER_ROLE_SLUG = 'project:personalOwner';
function isMySQLOrMariaDB(dbType: string): boolean {
return dbType === 'mysqldb' || dbType === 'mariadb';
}
/**
* Adds workflow:unpublish scope to all custom (non-personal-owner) roles that have workflow:publish.
*
* This migration ensures backward compatibility after the introduction of the workflow:unpublish scope.
* Roles that could publish workflows should also be able to unpublish them.
* project:personalOwner is excluded because it already has workflow:unpublish in its base definition (PERSONAL_PROJECT_OWNER_SCOPES).
*
* This migration:
* 1. Ensures the workflow:unpublish scope exists in the scope table
* 2. Finds all roles (except project:personalOwner) with workflow:publish and grants workflow:unpublish to them
*
* Compatible with SQLite, PostgreSQL, MySQL, and MariaDB.
*/
export class AddWorkflowUnpublishScopeToCustomRoles1769900001000 implements ReversibleMigration {
async up({ escape, runQuery, dbType }: MigrationContext) {
const scopeTableName = escape.tableName('scope');
const scopeSlugColumn = escape.columnName('slug');
const displayNameColumn = escape.columnName('displayName');
const descriptionColumn = escape.columnName('description');
const roleTableName = escape.tableName('role');
const roleScopeTableName = escape.tableName('role_scope');
const roleSlugColumn = escape.columnName('slug');
const roleScopeRoleSlugColumn = escape.columnName('roleSlug');
const roleScopeScopeSlugColumn = escape.columnName('scopeSlug');
const dbTypeStr = dbType as string;
const useInsertIgnore = isMySQLOrMariaDB(dbTypeStr);
// Step 1: Ensure workflow:unpublish scope exists
const insertScopeQuery = useInsertIgnore
? `INSERT IGNORE INTO ${scopeTableName} (${scopeSlugColumn}, ${displayNameColumn}, ${descriptionColumn})
VALUES (:slug, :displayName, :description)`
: `INSERT INTO ${scopeTableName} (${scopeSlugColumn}, ${displayNameColumn}, ${descriptionColumn})
VALUES (:slug, :displayName, :description)
ON CONFLICT (${scopeSlugColumn}) DO NOTHING`;
await runQuery(insertScopeQuery, {
slug: 'workflow:unpublish',
displayName: 'Unpublish Workflow',
description: 'Allows unpublishing workflows.',
});
// Step 2: Add workflow:unpublish to roles that have workflow:publish, excluding project:personalOwner
const batchInsertBase = `
INSERT ${useInsertIgnore ? 'IGNORE ' : ''}INTO ${roleScopeTableName} (${roleScopeRoleSlugColumn}, ${roleScopeScopeSlugColumn})
SELECT DISTINCT role.${roleSlugColumn}, :unpublishScope
FROM ${roleTableName} role
INNER JOIN ${roleScopeTableName} role_scope
ON role.${roleSlugColumn} = role_scope.${roleScopeRoleSlugColumn}
WHERE role.${roleSlugColumn} != :personalOwnerSlug
AND role_scope.${roleScopeScopeSlugColumn} = :publishScope
`;
const batchInsertQuery = useInsertIgnore
? batchInsertBase
: `${batchInsertBase}
ON CONFLICT (${roleScopeRoleSlugColumn}, ${roleScopeScopeSlugColumn}) DO NOTHING
`;
await runQuery(batchInsertQuery, {
personalOwnerSlug: PERSONAL_OWNER_ROLE_SLUG,
publishScope: 'workflow:publish',
unpublishScope: 'workflow:unpublish',
});
}
async down({ escape, runQuery }: MigrationContext) {
const roleScopeTableName = escape.tableName('role_scope');
const roleScopeScopeSlugColumn = escape.columnName('scopeSlug');
// Remove workflow:unpublish only from roles that are not project:personalOwner
// (personal owner keeps workflow:unpublish from base definition)
const deleteQuery = `
DELETE FROM ${roleScopeTableName}
WHERE ${roleScopeScopeSlugColumn} = :unpublishScope
`;
await runQuery(deleteQuery, {
unpublishScope: 'workflow:unpublish',
personalOwnerSlug: PERSONAL_OWNER_ROLE_SLUG,
});
}
}
@@ -0,0 +1,216 @@
import { randomUUID } from 'node:crypto';
import type { ReversibleMigration, MigrationContext } from '../migration-types';
const table = {
tools: 'chat_hub_tools',
sessions: 'chat_hub_sessions',
agents: 'chat_hub_agents',
sessionTools: 'chat_hub_session_tools',
agentTools: 'chat_hub_agent_tools',
user: 'user',
} as const;
interface SessionRow {
id: string;
ownerId: string;
tools: string;
}
interface AgentRow {
id: string;
ownerId: string;
tools: string;
}
/** The actual tools in the JSON data contain full INode definitions. */
interface ToolDefinition extends Record<string, unknown> {
id: string;
name: string;
type: string;
typeVersion: number;
}
export class CreateChatHubToolsTable1770000000000 implements ReversibleMigration {
async up({
schemaBuilder: { createTable, column, dropColumns },
escape,
isPostgres,
runQuery,
runInBatches,
parseJson,
logger,
migrationName,
}: MigrationContext) {
// Create the chat_hub_tools table with type and typeVersion columns
await createTable(table.tools)
.withColumns(
column('id').uuid.primary,
column('name').varchar(255).notNull,
column('type').varchar(255).notNull,
column('typeVersion').double.notNull,
column('ownerId').uuid.notNull,
column('definition').json.notNull,
column('enabled').bool.notNull.default(true),
)
.withForeignKey('ownerId', {
tableName: table.user,
columnName: 'id',
onDelete: 'CASCADE',
})
.withIndexOn(['ownerId', 'name'], true).withTimestamps;
// Create join tables
await createTable(table.sessionTools)
.withColumns(column('sessionId').uuid.notNull.primary, column('toolId').uuid.notNull.primary)
.withForeignKey('sessionId', {
tableName: table.sessions,
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('toolId', {
tableName: table.tools,
columnName: 'id',
onDelete: 'CASCADE',
});
await createTable(table.agentTools)
.withColumns(column('agentId').uuid.notNull.primary, column('toolId').uuid.notNull.primary)
.withForeignKey('agentId', {
tableName: table.agents,
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('toolId', {
tableName: table.tools,
columnName: 'id',
onDelete: 'CASCADE',
});
// Data migration: move tools from chat hub sessions and agents to the new chat_hub_tools table.
// Before this migration tools were stored as full INode definitions in a JSON column on sessions and agents.
// In practice we only supported three hardcoded tools, but each session held unique copies of them.
// Now we want to normalize and move them to a new table. Do the normalization by building a
// per-user tool name -> tool ID map across sessions and agents, and only insert the tool once per user.
const toolsByUserAndName = new Map<string, string>(); // key: `${ownerId}::${name}` -> toolId
const sessionsTable = escape.tableName(table.sessions);
const agentsTable = escape.tableName(table.agents);
const toolsTable = escape.tableName(table.tools);
const sessionToolsTable = escape.tableName(table.sessionTools);
const agentToolsTable = escape.tableName(table.agentTools);
const toolsFilter = isPostgres ? '"tools"::text != \'[]\'' : '"tools" != \'[]\'';
// Helper to ensure a tool exists in chat_hub_tools and return its ID
async function ensureTool(ownerId: string, def: ToolDefinition): Promise<string> {
const key = `${ownerId}::${def.name}`;
const existing = toolsByUserAndName.get(key);
if (existing) return existing;
const toolId = randomUUID();
await runQuery(
`INSERT INTO ${toolsTable} ("id", "name", "type", "typeVersion", "ownerId", "definition", "enabled")
VALUES (:id, :name, :type, :typeVersion, :ownerId, :definition, :enabled)`,
{
id: toolId,
name: def.name,
type: def.type,
typeVersion: def.typeVersion,
ownerId,
definition: JSON.stringify({ ...def, id: toolId }),
enabled: true,
},
);
toolsByUserAndName.set(key, toolId);
return toolId;
}
// Light validation, discard data that doesn't look like a valid tool definition to avoid inserting junk.
function isValidTool(tool: ToolDefinition): boolean {
return Boolean(tool.id && tool.name && tool.type && typeof tool.typeVersion === 'number');
}
function safeParseTools(raw: string, entityId: string, entityType: string): ToolDefinition[] {
try {
const tools = parseJson<ToolDefinition[]>(raw);
if (!Array.isArray(tools)) {
logger.warn(
`[${migrationName}] Tools column for ${entityType} ${entityId} is not an array. Skipping.`,
);
return [];
}
return tools;
} catch (error) {
logger.warn(
`[${migrationName}] Failed to parse tools for ${entityType} ${entityId}: ${error instanceof Error ? error.message : 'Unknown error'}. Skipping.`,
);
return [];
}
}
// Migrate chat hub sessions
await runInBatches<SessionRow>(
`SELECT "id", "ownerId", "tools" FROM ${sessionsTable} WHERE ${toolsFilter}`,
async (sessions) => {
for (const session of sessions) {
const tools = safeParseTools(session.tools, session.id, 'session');
const insertedToolIds = new Set<string>();
for (const tool of tools) {
if (!isValidTool(tool)) continue;
const toolId = await ensureTool(session.ownerId, tool);
if (insertedToolIds.has(toolId)) continue;
insertedToolIds.add(toolId);
await runQuery(
`INSERT INTO ${sessionToolsTable} ("sessionId", "toolId") VALUES (:sessionId, :toolId)`,
{ sessionId: session.id, toolId },
);
}
}
},
);
// Migrate chat hub agents
await runInBatches<AgentRow>(
`SELECT "id", "ownerId", "tools" FROM ${agentsTable} WHERE ${toolsFilter}`,
async (agents) => {
for (const agent of agents) {
const tools = safeParseTools(agent.tools, agent.id, 'agent');
const insertedToolIds = new Set<string>();
for (const tool of tools) {
if (!isValidTool(tool)) continue;
const toolId = await ensureTool(agent.ownerId, tool);
if (insertedToolIds.has(toolId)) continue;
insertedToolIds.add(toolId);
await runQuery(
`INSERT INTO ${agentToolsTable} ("agentId", "toolId") VALUES (:agentId, :toolId)`,
{ agentId: agent.id, toolId },
);
}
}
},
);
// Drop the tools columns from chat hub sessions and agents
await dropColumns(table.sessions, ['tools']);
await dropColumns(table.agents, ['tools']);
}
async down({ schemaBuilder: { addColumns, column, dropTable } }: MigrationContext) {
await dropTable(table.sessionTools);
await dropTable(table.agentTools);
await dropTable(table.tools);
// This loses data, but we can't really restore it.
// Impact of losing the configured tools should be fairly minimal, as credentials remain intact
// and users can easily re-add the search tools to chat hub sessions and agents after the rollback if needed.
await addColumns(table.sessions, [column('tools').json.notNull.default("'[]'")]);
await addColumns(table.agents, [column('tools').json.notNull.default("'[]'")]);
}
}

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