first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import { wrapMigration } from '../migration-helpers';
|
||||
import type { IrreversibleMigration, ReversibleMigration } from '../migration-types';
|
||||
|
||||
describe('migrationHelpers.wrapMigration', () => {
|
||||
test('throws if passed a migration without up method', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
class TestMigration {}
|
||||
|
||||
//
|
||||
// ACT & ASSERT
|
||||
//
|
||||
expect(() => wrapMigration(TestMigration as never)).toThrow(
|
||||
'Migration "TestMigration" is missing the method `up`.',
|
||||
);
|
||||
});
|
||||
|
||||
test('wraps up method', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
class TestMigration implements IrreversibleMigration {
|
||||
async up() {}
|
||||
}
|
||||
const originalUp = jest.fn();
|
||||
TestMigration.prototype.up = originalUp;
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
wrapMigration(TestMigration);
|
||||
await new TestMigration().up();
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(TestMigration.prototype.up).not.toBe(originalUp);
|
||||
expect(originalUp).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('wraps down method', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
class TestMigration implements ReversibleMigration {
|
||||
async up() {}
|
||||
|
||||
async down() {}
|
||||
}
|
||||
const originalDown = jest.fn();
|
||||
TestMigration.prototype.down = originalDown;
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
wrapMigration(TestMigration);
|
||||
await new TestMigration().down();
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(TestMigration.prototype.down).not.toBe(originalDown);
|
||||
expect(originalDown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { WorkflowEntity } from '../../entities';
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
export class UniqueWorkflowNames1620821879465 implements ReversibleMigration {
|
||||
protected indexSuffix = '943d8f922be094eb507cb9a7f9';
|
||||
|
||||
async up({ escape, runQuery }: MigrationContext) {
|
||||
const tableName = escape.tableName('workflow_entity');
|
||||
const workflowNames: Array<Pick<WorkflowEntity, 'name'>> = await runQuery(
|
||||
`SELECT name FROM ${tableName}`,
|
||||
);
|
||||
|
||||
for (const { name } of workflowNames) {
|
||||
const duplicates: Array<Pick<WorkflowEntity, 'id' | 'name'>> = await runQuery(
|
||||
`SELECT id, name FROM ${tableName} WHERE name = :name ORDER BY createdAt ASC`,
|
||||
{ name },
|
||||
);
|
||||
|
||||
if (duplicates.length > 1) {
|
||||
await Promise.all(
|
||||
duplicates.map(async (workflow, index) => {
|
||||
if (index === 0) return;
|
||||
return await runQuery(`UPDATE ${tableName} SET name = :name WHERE id = :id`, {
|
||||
name: `${workflow.name} ${index + 1}`,
|
||||
id: workflow.id,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const indexName = escape.indexName(this.indexSuffix);
|
||||
await runQuery(`CREATE UNIQUE INDEX ${indexName} ON ${tableName} ("name")`);
|
||||
}
|
||||
|
||||
async down({ escape, runQuery }: MigrationContext) {
|
||||
const indexName = escape.indexName(this.indexSuffix);
|
||||
await runQuery(`DROP INDEX ${indexName}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
import type { IWorkflowBase } from 'n8n-workflow';
|
||||
|
||||
import type { CredentialsEntity, WorkflowEntity } from '../../entities';
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
type Credential = Pick<CredentialsEntity, 'id' | 'name' | 'type'>;
|
||||
type ExecutionWithData = { id: string; workflowData: string | IWorkflowBase };
|
||||
type Workflow = Pick<WorkflowEntity, 'id'> & { nodes: string | WorkflowEntity['nodes'] };
|
||||
|
||||
// replacing the credentials in workflows and execution
|
||||
// `nodeType: name` changes to `nodeType: { id, name }`
|
||||
|
||||
export class UpdateWorkflowCredentials1630330987096 implements ReversibleMigration {
|
||||
async up({ dbType, escape, parseJson, runQuery, runInBatches }: MigrationContext) {
|
||||
const credentialsTable = escape.tableName('credentials_entity');
|
||||
const workflowsTable = escape.tableName('workflow_entity');
|
||||
const executionsTable = escape.tableName('execution_entity');
|
||||
const dataColumn = escape.columnName('workflowData');
|
||||
const waitTillColumn = escape.columnName('waitTill');
|
||||
|
||||
const credentialsEntities: Credential[] = await runQuery(
|
||||
`SELECT id, name, type FROM ${credentialsTable}`,
|
||||
);
|
||||
|
||||
const workflowsQuery = `SELECT id, nodes FROM ${workflowsTable}`;
|
||||
await runInBatches<Workflow>(workflowsQuery, async (workflows) => {
|
||||
workflows.forEach(async (workflow) => {
|
||||
let credentialsUpdated = false;
|
||||
const nodes = parseJson(workflow.nodes);
|
||||
nodes.forEach((node) => {
|
||||
if (node.credentials) {
|
||||
const allNodeCredentials = Object.entries(node.credentials);
|
||||
for (const [type, name] of allNodeCredentials) {
|
||||
if (typeof name === 'string') {
|
||||
const matchingCredentials = credentialsEntities.find(
|
||||
(credentials) => credentials.name === name && credentials.type === type,
|
||||
);
|
||||
node.credentials[type] = { id: matchingCredentials?.id ?? null, name };
|
||||
credentialsUpdated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (credentialsUpdated) {
|
||||
await runQuery(`UPDATE ${workflowsTable} SET nodes = :nodes WHERE id = :id`, {
|
||||
nodes: JSON.stringify(nodes),
|
||||
id: workflow.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const finishedValue = dbType === 'postgresdb' ? 'FALSE' : '0';
|
||||
const waitingExecutionsQuery = `
|
||||
SELECT id, ${dataColumn}
|
||||
FROM ${executionsTable}
|
||||
WHERE ${waitTillColumn} IS NOT NULL AND finished = ${finishedValue}
|
||||
`;
|
||||
await runInBatches<ExecutionWithData>(waitingExecutionsQuery, async (waitingExecutions) => {
|
||||
waitingExecutions.forEach(async (execution) => {
|
||||
let credentialsUpdated = false;
|
||||
const workflowData = parseJson(execution.workflowData);
|
||||
workflowData.nodes.forEach((node) => {
|
||||
if (node.credentials) {
|
||||
const allNodeCredentials = Object.entries(node.credentials);
|
||||
for (const [type, name] of allNodeCredentials) {
|
||||
if (typeof name === 'string') {
|
||||
const matchingCredentials = credentialsEntities.find(
|
||||
(credentials) => credentials.name === name && credentials.type === type,
|
||||
);
|
||||
node.credentials[type] = { id: matchingCredentials?.id ?? null, name };
|
||||
credentialsUpdated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (credentialsUpdated) {
|
||||
await runQuery(
|
||||
`UPDATE ${executionsTable}
|
||||
SET ${escape.columnName('workflowData')} = :data WHERE id = :id`,
|
||||
{ data: JSON.stringify(workflowData), id: execution.id },
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const retryableExecutions: ExecutionWithData[] = await runQuery(`
|
||||
SELECT id, ${dataColumn}
|
||||
FROM ${executionsTable}
|
||||
WHERE ${waitTillColumn} IS NULL AND finished = ${finishedValue} AND mode != 'retry'
|
||||
ORDER BY ${escape.columnName('startedAt')} DESC
|
||||
LIMIT 200
|
||||
`);
|
||||
retryableExecutions.forEach(async (execution) => {
|
||||
let credentialsUpdated = false;
|
||||
const workflowData = parseJson(execution.workflowData);
|
||||
workflowData.nodes.forEach((node) => {
|
||||
if (node.credentials) {
|
||||
const allNodeCredentials = Object.entries(node.credentials);
|
||||
for (const [type, name] of allNodeCredentials) {
|
||||
if (typeof name === 'string') {
|
||||
const matchingCredentials = credentialsEntities.find(
|
||||
(credentials) => credentials.name === name && credentials.type === type,
|
||||
);
|
||||
node.credentials[type] = { id: matchingCredentials?.id ?? null, name };
|
||||
credentialsUpdated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (credentialsUpdated) {
|
||||
await runQuery(
|
||||
`UPDATE ${executionsTable}
|
||||
SET ${escape.columnName('workflowData')} = :data WHERE id = :id`,
|
||||
{ data: JSON.stringify(workflowData), id: execution.id },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async down({ dbType, escape, parseJson, runQuery, runInBatches }: MigrationContext) {
|
||||
const credentialsTable = escape.tableName('credentials_entity');
|
||||
const workflowsTable = escape.tableName('workflow_entity');
|
||||
const executionsTable = escape.tableName('execution_entity');
|
||||
const dataColumn = escape.columnName('workflowData');
|
||||
const waitTillColumn = escape.columnName('waitTill');
|
||||
|
||||
const credentialsEntities: Credential[] = await runQuery(
|
||||
`SELECT id, name, type FROM ${credentialsTable}`,
|
||||
);
|
||||
|
||||
const workflowsQuery = `SELECT id, nodes FROM ${workflowsTable}`;
|
||||
await runInBatches<Workflow>(workflowsQuery, async (workflows) => {
|
||||
workflows.forEach(async (workflow) => {
|
||||
let credentialsUpdated = false;
|
||||
const nodes = parseJson(workflow.nodes);
|
||||
nodes.forEach((node) => {
|
||||
if (node.credentials) {
|
||||
const allNodeCredentials = Object.entries(node.credentials);
|
||||
for (const [type, creds] of allNodeCredentials) {
|
||||
if (typeof creds === 'object') {
|
||||
const matchingCredentials = credentialsEntities.find(
|
||||
// double-equals because creds.id can be string or number
|
||||
// eslint-disable-next-line eqeqeq
|
||||
(credentials) => credentials.id == creds.id && credentials.type === type,
|
||||
);
|
||||
if (matchingCredentials) {
|
||||
// @ts-ignore
|
||||
node.credentials[type] = matchingCredentials.name;
|
||||
} else {
|
||||
// @ts-ignore
|
||||
node.credentials[type] = creds.name;
|
||||
}
|
||||
credentialsUpdated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (credentialsUpdated) {
|
||||
await runQuery(`UPDATE ${workflowsTable} SET nodes = :nodes WHERE id = :id`, {
|
||||
nodes: JSON.stringify(nodes),
|
||||
id: workflow.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const finishedValue = dbType === 'postgresdb' ? 'FALSE' : '0';
|
||||
const waitingExecutionsQuery = `
|
||||
SELECT id, ${dataColumn}
|
||||
FROM ${executionsTable}
|
||||
WHERE ${waitTillColumn} IS NOT NULL AND finished = ${finishedValue}
|
||||
`;
|
||||
|
||||
await runInBatches<ExecutionWithData>(waitingExecutionsQuery, async (waitingExecutions) => {
|
||||
waitingExecutions.forEach(async (execution) => {
|
||||
let credentialsUpdated = false;
|
||||
const workflowData = parseJson(execution.workflowData);
|
||||
workflowData.nodes.forEach((node) => {
|
||||
if (node.credentials) {
|
||||
const allNodeCredentials = Object.entries(node.credentials);
|
||||
for (const [type, creds] of allNodeCredentials) {
|
||||
if (typeof creds === 'object') {
|
||||
const matchingCredentials = credentialsEntities.find(
|
||||
// double-equals because creds.id can be string or number
|
||||
// eslint-disable-next-line eqeqeq
|
||||
(credentials) => credentials.id == creds.id && credentials.type === type,
|
||||
);
|
||||
if (matchingCredentials) {
|
||||
// @ts-ignore
|
||||
node.credentials[type] = matchingCredentials.name;
|
||||
} else {
|
||||
// @ts-ignore
|
||||
node.credentials[type] = creds.name;
|
||||
}
|
||||
credentialsUpdated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (credentialsUpdated) {
|
||||
await runQuery(
|
||||
`UPDATE ${executionsTable}
|
||||
SET ${escape.columnName('workflowData')} = :data WHERE id = :id`,
|
||||
{ data: JSON.stringify(workflowData), id: execution.id },
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const retryableExecutions: ExecutionWithData[] = await runQuery(`
|
||||
SELECT id, ${dataColumn}
|
||||
FROM ${executionsTable}
|
||||
WHERE ${waitTillColumn} IS NULL AND finished = ${finishedValue} AND mode != 'retry'
|
||||
ORDER BY ${escape.columnName('startedAt')} DESC
|
||||
LIMIT 200
|
||||
`);
|
||||
retryableExecutions.forEach(async (execution) => {
|
||||
let credentialsUpdated = false;
|
||||
const workflowData = parseJson(execution.workflowData);
|
||||
workflowData.nodes.forEach((node) => {
|
||||
if (node.credentials) {
|
||||
const allNodeCredentials = Object.entries(node.credentials);
|
||||
for (const [type, creds] of allNodeCredentials) {
|
||||
if (typeof creds === 'object') {
|
||||
const matchingCredentials = credentialsEntities.find(
|
||||
// double-equals because creds.id can be string or number
|
||||
// eslint-disable-next-line eqeqeq
|
||||
(credentials) => credentials.id == creds.id && credentials.type === type,
|
||||
);
|
||||
if (matchingCredentials) {
|
||||
// @ts-ignore
|
||||
node.credentials[type] = matchingCredentials.name;
|
||||
} else {
|
||||
// @ts-ignore
|
||||
node.credentials[type] = creds.name;
|
||||
}
|
||||
credentialsUpdated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (credentialsUpdated) {
|
||||
await runQuery(
|
||||
`UPDATE ${executionsTable}
|
||||
SET ${escape.columnName('workflowData')} = :data WHERE id = :id`,
|
||||
{ data: JSON.stringify(workflowData), id: execution.id },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import type { WorkflowEntity } from '../../entities';
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
type Workflow = Pick<WorkflowEntity, 'id'> & { nodes: string | INode[] };
|
||||
|
||||
export class AddNodeIds1658930531669 implements ReversibleMigration {
|
||||
async up({ escape, runQuery, runInBatches, parseJson }: MigrationContext) {
|
||||
const tableName = escape.tableName('workflow_entity');
|
||||
const workflowsQuery = `SELECT id, nodes FROM ${tableName}`;
|
||||
await runInBatches<Workflow>(workflowsQuery, async (workflows) => {
|
||||
workflows.forEach(async (workflow) => {
|
||||
const nodes = parseJson(workflow.nodes);
|
||||
nodes.forEach((node: INode) => {
|
||||
if (!node.id) {
|
||||
node.id = uuid();
|
||||
}
|
||||
});
|
||||
|
||||
await runQuery(`UPDATE ${tableName} SET nodes = :nodes WHERE id = :id`, {
|
||||
nodes: JSON.stringify(nodes),
|
||||
id: workflow.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async down({ escape, runQuery, runInBatches, parseJson }: MigrationContext) {
|
||||
const tableName = escape.tableName('workflow_entity');
|
||||
const workflowsQuery = `SELECT id, nodes FROM ${tableName}`;
|
||||
await runInBatches<Workflow>(workflowsQuery, async (workflows) => {
|
||||
workflows.forEach(async (workflow) => {
|
||||
const nodes = parseJson(workflow.nodes).map(({ id, ...rest }) => rest);
|
||||
await runQuery(`UPDATE ${tableName} SET nodes = :nodes WHERE id = :id`, {
|
||||
nodes: JSON.stringify(nodes),
|
||||
id: workflow.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { isObjectLiteral } from '@n8n/backend-common';
|
||||
import type { IDataObject, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import type { MigrationContext, IrreversibleMigration } from '../migration-types';
|
||||
|
||||
type OldPinnedData = { [nodeName: string]: IDataObject[] };
|
||||
type NewPinnedData = { [nodeName: string]: INodeExecutionData[] };
|
||||
type Workflow = { id: number; pinData: string | OldPinnedData };
|
||||
|
||||
function isJsonKeyObject(item: unknown): item is {
|
||||
json: unknown;
|
||||
[keys: string]: unknown;
|
||||
} {
|
||||
if (!isObjectLiteral(item)) return false;
|
||||
return Object.keys(item).includes('json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert TEXT-type `pinData` column in `workflow_entity` table from
|
||||
* `{ [nodeName: string]: IDataObject[] }` to `{ [nodeName: string]: INodeExecutionData[] }`
|
||||
*/
|
||||
export class AddJsonKeyPinData1659888469333 implements IrreversibleMigration {
|
||||
async up({ escape, runQuery, runInBatches }: MigrationContext) {
|
||||
const tableName = escape.tableName('workflow_entity');
|
||||
const columnName = escape.columnName('pinData');
|
||||
|
||||
const selectQuery = `SELECT id, ${columnName} FROM ${tableName} WHERE ${columnName} IS NOT NULL`;
|
||||
await runInBatches<Workflow>(selectQuery, async (workflows) => {
|
||||
await Promise.all(
|
||||
this.makeUpdateParams(workflows).map(
|
||||
async (workflow) =>
|
||||
await runQuery(`UPDATE ${tableName} SET ${columnName} = :pinData WHERE id = :id;`, {
|
||||
pinData: workflow.pinData,
|
||||
id: workflow.id,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private makeUpdateParams(fetchedWorkflows: Workflow[]) {
|
||||
return fetchedWorkflows.reduce<Workflow[]>((updateParams, { id, pinData: rawPinData }) => {
|
||||
let pinDataPerWorkflow: OldPinnedData | NewPinnedData;
|
||||
|
||||
if (typeof rawPinData === 'string') {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
pinDataPerWorkflow = JSON.parse(rawPinData);
|
||||
} catch {
|
||||
pinDataPerWorkflow = {};
|
||||
}
|
||||
} else {
|
||||
pinDataPerWorkflow = rawPinData;
|
||||
}
|
||||
|
||||
const newPinDataPerWorkflow = Object.keys(pinDataPerWorkflow).reduce<NewPinnedData>(
|
||||
(newPinDataPerWorkflow, nodeName) => {
|
||||
let pinDataPerNode = pinDataPerWorkflow[nodeName];
|
||||
|
||||
if (!Array.isArray(pinDataPerNode)) {
|
||||
pinDataPerNode = [pinDataPerNode];
|
||||
}
|
||||
|
||||
if (pinDataPerNode.every((item) => item.json)) return newPinDataPerWorkflow;
|
||||
|
||||
newPinDataPerWorkflow[nodeName] = pinDataPerNode.map((item) =>
|
||||
isJsonKeyObject(item) ? item : { json: item },
|
||||
);
|
||||
|
||||
return newPinDataPerWorkflow;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
if (Object.keys(newPinDataPerWorkflow).length > 0) {
|
||||
updateParams.push({ id, pinData: JSON.stringify(newPinDataPerWorkflow) });
|
||||
}
|
||||
|
||||
return updateParams;
|
||||
}, []);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
type Workflow = { id: number };
|
||||
|
||||
export class AddWorkflowVersionIdColumn1669739707124 implements ReversibleMigration {
|
||||
async up({ escape, runQuery }: MigrationContext) {
|
||||
const tableName = escape.tableName('workflow_entity');
|
||||
const columnName = escape.columnName('versionId');
|
||||
|
||||
await runQuery(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} CHAR(36)`);
|
||||
|
||||
const workflowIds: Workflow[] = await runQuery(`SELECT id FROM ${tableName}`);
|
||||
for (const { id } of workflowIds) {
|
||||
await runQuery(`UPDATE ${tableName} SET ${columnName} = :versionId WHERE id = :id`, {
|
||||
versionId: uuidv4(),
|
||||
id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async down({ escape, runQuery }: MigrationContext) {
|
||||
const tableName = escape.tableName('workflow_entity');
|
||||
const columnName = escape.columnName('versionId');
|
||||
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { StatisticsNames } from '../../entities/types-db';
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
export class RemoveWorkflowDataLoadedFlag1671726148419 implements ReversibleMigration {
|
||||
async up({ escape, dbType, runQuery }: MigrationContext) {
|
||||
const workflowTableName = escape.tableName('workflow_entity');
|
||||
const statisticsTableName = escape.tableName('workflow_statistics');
|
||||
const columnName = escape.columnName('dataLoaded');
|
||||
|
||||
// If any existing workflow has dataLoaded set to true, insert the relevant information to the statistics table
|
||||
const workflowIds: Array<{ id: number; dataLoaded: boolean }> = await runQuery(
|
||||
`SELECT id, ${columnName} FROM ${workflowTableName}`,
|
||||
);
|
||||
|
||||
const now =
|
||||
dbType === 'sqlite' ? "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')" : 'CURRENT_TIMESTAMP(3)';
|
||||
|
||||
await Promise.all(
|
||||
workflowIds.map(
|
||||
async ({ id, dataLoaded }) =>
|
||||
await (dataLoaded &&
|
||||
runQuery(
|
||||
`INSERT INTO ${statisticsTableName}
|
||||
(${escape.columnName('workflowId')}, name, count, ${escape.columnName('latestEvent')})
|
||||
VALUES (:id, :name, 1, ${now})`,
|
||||
{ id, name: StatisticsNames.dataLoaded },
|
||||
)),
|
||||
),
|
||||
);
|
||||
|
||||
await runQuery(`ALTER TABLE ${workflowTableName} DROP COLUMN ${columnName}`);
|
||||
}
|
||||
|
||||
async down({ escape, runQuery }: MigrationContext) {
|
||||
const workflowTableName = escape.tableName('workflow_entity');
|
||||
const statisticsTableName = escape.tableName('workflow_statistics');
|
||||
const columnName = escape.columnName('dataLoaded');
|
||||
|
||||
await runQuery(
|
||||
`ALTER TABLE ${workflowTableName} ADD COLUMN ${columnName} BOOLEAN DEFAULT false`,
|
||||
);
|
||||
|
||||
// Search through statistics for any workflows that have the dataLoaded stat
|
||||
const workflowsIds: Array<{ workflowId: string }> = await runQuery(
|
||||
`SELECT ${escape.columnName('workflowId')} FROM ${statisticsTableName} WHERE name = :name`,
|
||||
{ name: StatisticsNames.dataLoaded },
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
workflowsIds.map(
|
||||
async ({ workflowId }) =>
|
||||
await runQuery(`UPDATE ${workflowTableName} SET ${columnName} = true WHERE id = :id`, {
|
||||
id: workflowId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await runQuery(`DELETE FROM ${statisticsTableName} WHERE name = :name`, {
|
||||
name: StatisticsNames.dataLoaded,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { LDAP_FEATURE_NAME, LDAP_DEFAULT_CONFIGURATION } from '@n8n/constants';
|
||||
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
export class CreateLdapEntities1674509946020 implements ReversibleMigration {
|
||||
async up({ escape, dbType, runQuery }: MigrationContext) {
|
||||
const userTable = escape.tableName('user');
|
||||
await runQuery(`ALTER TABLE ${userTable} ADD COLUMN disabled BOOLEAN NOT NULL DEFAULT false;`);
|
||||
|
||||
await runQuery(`
|
||||
INSERT INTO ${escape.tableName('settings')}
|
||||
(${escape.columnName('key')}, value, ${escape.columnName('loadOnStartup')})
|
||||
VALUES ('${LDAP_FEATURE_NAME}', '${JSON.stringify(LDAP_DEFAULT_CONFIGURATION)}', true)
|
||||
`);
|
||||
|
||||
const uuidColumnType = dbType === 'postgresdb' ? 'UUID' : 'VARCHAR(36)';
|
||||
|
||||
await runQuery(
|
||||
`CREATE TABLE IF NOT EXISTS ${escape.tableName('auth_identity')} (
|
||||
${escape.columnName('userId')} ${uuidColumnType} REFERENCES ${userTable} (id),
|
||||
${escape.columnName('providerId')} VARCHAR(64) NOT NULL,
|
||||
${escape.columnName('providerType')} VARCHAR(32) NOT NULL,
|
||||
${escape.columnName('createdAt')} timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
${escape.columnName('updatedAt')} timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY(${escape.columnName('providerId')}, ${escape.columnName('providerType')})
|
||||
)`,
|
||||
);
|
||||
|
||||
const idColumn =
|
||||
dbType === 'sqlite'
|
||||
? 'INTEGER PRIMARY KEY AUTOINCREMENT'
|
||||
: dbType === 'postgresdb'
|
||||
? 'SERIAL NOT NULL PRIMARY KEY'
|
||||
: 'INTEGER NOT NULL AUTO_INCREMENT';
|
||||
|
||||
const timestampColumn =
|
||||
dbType === 'sqlite'
|
||||
? 'DATETIME NOT NULL'
|
||||
: dbType === 'postgresdb'
|
||||
? 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP'
|
||||
: 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP';
|
||||
|
||||
await runQuery(
|
||||
`CREATE TABLE IF NOT EXISTS ${escape.tableName('auth_provider_sync_history')} (
|
||||
${escape.columnName('id')} ${idColumn},
|
||||
${escape.columnName('providerType')} VARCHAR(32) NOT NULL,
|
||||
${escape.columnName('runMode')} TEXT NOT NULL,
|
||||
${escape.columnName('status')} TEXT NOT NULL,
|
||||
${escape.columnName('startedAt')} ${timestampColumn},
|
||||
${escape.columnName('endedAt')} ${timestampColumn},
|
||||
${escape.columnName('scanned')} INTEGER NOT NULL,
|
||||
${escape.columnName('created')} INTEGER NOT NULL,
|
||||
${escape.columnName('updated')} INTEGER NOT NULL,
|
||||
${escape.columnName('disabled')} INTEGER NOT NULL,
|
||||
${escape.columnName('error')} TEXT
|
||||
)`,
|
||||
);
|
||||
}
|
||||
|
||||
async down({ escape, runQuery }: MigrationContext) {
|
||||
await runQuery(`DROP TABLE "${escape.tableName('auth_provider_sync_history')}`);
|
||||
await runQuery(`DROP TABLE "${escape.tableName('auth_identity')}`);
|
||||
await runQuery(`DELETE FROM ${escape.tableName('settings')} WHERE key = :key`, {
|
||||
key: LDAP_FEATURE_NAME,
|
||||
});
|
||||
await runQuery(`ALTER TABLE ${escape.tableName('user')} DROP COLUMN disabled`);
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
import { WorkflowEntity } from '../../entities';
|
||||
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
|
||||
|
||||
export class PurgeInvalidWorkflowConnections1675940580449 implements IrreversibleMigration {
|
||||
async up({ queryRunner }: MigrationContext) {
|
||||
const workflowCount = await queryRunner.manager.count(WorkflowEntity);
|
||||
|
||||
if (workflowCount > 0) {
|
||||
throw new UserError(
|
||||
'Migration "PurgeInvalidWorkflowConnections1675940580449" is no longer supported. Please upgrade to n8n@1.0.0 first.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
export class RemoveResetPasswordColumns1690000000030 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { dropColumns } }: MigrationContext) {
|
||||
await dropColumns('user', ['resetPasswordToken', 'resetPasswordTokenExpiration']);
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { addColumns, column } }: MigrationContext) {
|
||||
await addColumns('user', [
|
||||
column('resetPasswordToken').varchar(),
|
||||
column('resetPasswordTokenExpiration').int,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
export class AddMfaColumns1690000000030 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
|
||||
await addColumns('user', [
|
||||
column('mfaEnabled').bool.notNull.default(false),
|
||||
column('mfaSecret').text,
|
||||
column('mfaRecoveryCodes').text,
|
||||
]);
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
|
||||
await dropColumns('user', ['mfaEnabled', 'mfaSecret', 'mfaRecoveryCodes']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
export class CreateWorkflowNameIndex1691088862123 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { createIndex } }: MigrationContext) {
|
||||
await createIndex('workflow_entity', ['name']);
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropIndex } }: MigrationContext) {
|
||||
await dropIndex('workflow_entity', ['name']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const tableName = 'workflow_history';
|
||||
|
||||
export class CreateWorkflowHistoryTable1692967111175 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
|
||||
await createTable(tableName)
|
||||
.withColumns(
|
||||
column('versionId').varchar(36).primary.notNull,
|
||||
column('workflowId').varchar(36).notNull,
|
||||
column('nodes').text.notNull,
|
||||
column('connections').text.notNull,
|
||||
column('authors').varchar(255).notNull,
|
||||
)
|
||||
.withTimestamps.withIndexOn('workflowId')
|
||||
.withForeignKey('workflowId', {
|
||||
tableName: 'workflow_entity',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
|
||||
await dropTable(tableName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
/**
|
||||
* Add an indexed column `deletedAt` to track soft-deleted executions.
|
||||
* Add an index on `stoppedAt`, used by executions pruning.
|
||||
*/
|
||||
export class ExecutionSoftDelete1693491613982 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { addColumns, column, createIndex } }: MigrationContext) {
|
||||
await addColumns('execution_entity', [column('deletedAt').timestamp()]);
|
||||
await createIndex('execution_entity', ['deletedAt']);
|
||||
await createIndex('execution_entity', ['stoppedAt']);
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropColumns, dropIndex } }: MigrationContext) {
|
||||
await dropIndex('execution_entity', ['stoppedAt']);
|
||||
await dropIndex('execution_entity', ['deletedAt']);
|
||||
await dropColumns('execution_entity', ['deletedAt']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
export class DisallowOrphanExecutions1693554410387 implements ReversibleMigration {
|
||||
/**
|
||||
* Ensure all executions point to a workflow.
|
||||
*/
|
||||
async up({ escape, schemaBuilder: { addNotNull }, runQuery }: MigrationContext) {
|
||||
const executionEntity = escape.tableName('execution_entity');
|
||||
const workflowId = escape.columnName('workflowId');
|
||||
|
||||
await runQuery(`DELETE FROM ${executionEntity} WHERE ${workflowId} IS NULL;`);
|
||||
|
||||
await addNotNull('execution_entity', 'workflowId');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reversal excludes restoring deleted rows.
|
||||
*/
|
||||
async down({ schemaBuilder: { dropNotNull } }: MigrationContext) {
|
||||
await dropNotNull('execution_entity', 'workflowId');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
export class AddWorkflowMetadata1695128658538 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
|
||||
await addColumns('workflow_entity', [column('meta').json]);
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
|
||||
await dropColumns('workflow_entity', ['meta']);
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const tableName = 'workflow_history';
|
||||
|
||||
export class ModifyWorkflowHistoryNodesAndConnections1695829275184 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { addColumns, dropColumns, column } }: MigrationContext) {
|
||||
await dropColumns(tableName, ['nodes', 'connections']);
|
||||
await addColumns(tableName, [column('nodes').json.notNull, column('connections').json.notNull]);
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropColumns, addColumns, column } }: MigrationContext) {
|
||||
await dropColumns(tableName, ['nodes', 'connections']);
|
||||
await addColumns(tableName, [column('nodes').text.notNull, column('connections').text.notNull]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
export class AddGlobalAdminRole1700571993961 implements ReversibleMigration {
|
||||
async up({ escape, runQuery }: MigrationContext) {
|
||||
const tableName = escape.tableName('role');
|
||||
|
||||
await runQuery(`INSERT INTO ${tableName} (name, scope) VALUES (:name, :scope)`, {
|
||||
name: 'admin',
|
||||
scope: 'global',
|
||||
});
|
||||
}
|
||||
|
||||
async down({ escape, runQuery }: MigrationContext) {
|
||||
const roleTableName = escape.tableName('role');
|
||||
const userTableName = escape.tableName('user');
|
||||
|
||||
const adminRoleIdResult = await runQuery<Array<{ id: number }>>(
|
||||
`SELECT id FROM ${roleTableName} WHERE name = :name AND scope = :scope`,
|
||||
{
|
||||
name: 'admin',
|
||||
scope: 'global',
|
||||
},
|
||||
);
|
||||
|
||||
const memberRoleIdResult = await runQuery<Array<{ id: number }>>(
|
||||
`SELECT id FROM ${roleTableName} WHERE name = :name AND scope = :scope`,
|
||||
{
|
||||
name: 'member',
|
||||
scope: 'global',
|
||||
},
|
||||
);
|
||||
|
||||
const adminRoleId = adminRoleIdResult[0]?.id;
|
||||
if (adminRoleId === undefined) {
|
||||
// Couldn't find admin role. It's a bit odd but it means we don't
|
||||
// have anything to do.
|
||||
return;
|
||||
}
|
||||
|
||||
const memberRoleId = memberRoleIdResult[0]?.id;
|
||||
if (!memberRoleId) {
|
||||
throw new UnexpectedError('Could not find global member role!');
|
||||
}
|
||||
|
||||
await runQuery(
|
||||
`UPDATE ${userTableName} SET globalRoleId = :memberRoleId WHERE globalRoleId = :adminRoleId`,
|
||||
{
|
||||
memberRoleId,
|
||||
adminRoleId,
|
||||
},
|
||||
);
|
||||
|
||||
await runQuery(`DELETE FROM ${roleTableName} WHERE name = :name AND scope = :scope`, {
|
||||
name: 'admin',
|
||||
scope: 'global',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
type Table = 'user' | 'shared_workflow' | 'shared_credentials';
|
||||
|
||||
const idColumns: Record<Table, string> = {
|
||||
user: 'id',
|
||||
shared_credentials: 'credentialsId',
|
||||
shared_workflow: 'workflowId',
|
||||
};
|
||||
|
||||
const uidColumns: Record<Table, string> = {
|
||||
user: 'id',
|
||||
shared_credentials: 'userId',
|
||||
shared_workflow: 'userId',
|
||||
};
|
||||
|
||||
const roleScopes: Record<Table, string> = {
|
||||
user: 'global',
|
||||
shared_credentials: 'credential',
|
||||
shared_workflow: 'workflow',
|
||||
};
|
||||
|
||||
const foreignKeySuffixes: Record<Table, string> = {
|
||||
user: 'f0609be844f9200ff4365b1bb3d',
|
||||
shared_credentials: 'c68e056637562000b68f480815a',
|
||||
shared_workflow: '3540da03964527aa24ae014b780',
|
||||
};
|
||||
|
||||
export class DropRoleMapping1705429061930 implements ReversibleMigration {
|
||||
async up(context: MigrationContext) {
|
||||
await this.migrateUp('user', context);
|
||||
await this.migrateUp('shared_workflow', context);
|
||||
await this.migrateUp('shared_credentials', context);
|
||||
}
|
||||
|
||||
async down(context: MigrationContext) {
|
||||
await this.migrateDown('shared_workflow', context);
|
||||
await this.migrateDown('shared_credentials', context);
|
||||
await this.migrateDown('user', context);
|
||||
}
|
||||
|
||||
private async migrateUp(
|
||||
table: Table,
|
||||
{
|
||||
escape,
|
||||
runQuery,
|
||||
schemaBuilder: { addNotNull, addColumns, dropColumns, dropForeignKey, column },
|
||||
tablePrefix,
|
||||
}: MigrationContext,
|
||||
) {
|
||||
await addColumns(table, [column('role').text]);
|
||||
|
||||
const roleTable = escape.tableName('role');
|
||||
const tableName = escape.tableName(table);
|
||||
const idColumn = escape.columnName(idColumns[table]);
|
||||
const uidColumn = escape.columnName(uidColumns[table]);
|
||||
const roleColumnName = table === 'user' ? 'globalRoleId' : 'roleId';
|
||||
const roleColumn = escape.columnName(roleColumnName);
|
||||
const scope = roleScopes[table];
|
||||
const roleField = `'${scope}:' || R.name`;
|
||||
const subQuery = `
|
||||
SELECT ${roleField} as role, T.${idColumn} as id${
|
||||
table !== 'user' ? `, T.${uidColumn} as uid` : ''
|
||||
}
|
||||
FROM ${tableName} T
|
||||
LEFT JOIN ${roleTable} R
|
||||
ON T.${roleColumn} = R.id and R.scope = '${scope}'`;
|
||||
const where = `WHERE ${tableName}.${idColumn} = mapping.id${
|
||||
table !== 'user' ? ` AND ${tableName}.${uidColumn} = mapping.uid` : ''
|
||||
}`;
|
||||
const swQuery = `UPDATE ${tableName}
|
||||
SET role = mapping.role
|
||||
FROM (${subQuery}) as mapping
|
||||
${where}`;
|
||||
await runQuery(swQuery);
|
||||
|
||||
await addNotNull(table, 'role');
|
||||
|
||||
await dropForeignKey(
|
||||
table,
|
||||
roleColumnName,
|
||||
['role', 'id'],
|
||||
`FK_${tablePrefix}${foreignKeySuffixes[table]}`,
|
||||
);
|
||||
await dropColumns(table, [roleColumnName]);
|
||||
}
|
||||
|
||||
private async migrateDown(
|
||||
table: Table,
|
||||
{
|
||||
escape,
|
||||
runQuery,
|
||||
schemaBuilder: { addNotNull, addColumns, dropColumns, addForeignKey, column },
|
||||
tablePrefix,
|
||||
}: MigrationContext,
|
||||
) {
|
||||
const roleColumnName = table === 'user' ? 'globalRoleId' : 'roleId';
|
||||
await addColumns(table, [column(roleColumnName).int]);
|
||||
|
||||
const roleTable = escape.tableName('role');
|
||||
const tableName = escape.tableName(table);
|
||||
const idColumn = escape.columnName(idColumns[table]);
|
||||
const uidColumn = escape.columnName(uidColumns[table]);
|
||||
const roleColumn = escape.columnName(roleColumnName);
|
||||
const scope = roleScopes[table];
|
||||
const roleField = `'${scope}:' || R.name`;
|
||||
const subQuery = `
|
||||
SELECT R.id as role_id, T.${idColumn} as id${table !== 'user' ? `, T.${uidColumn} as uid` : ''}
|
||||
FROM ${tableName} T
|
||||
LEFT JOIN ${roleTable} R
|
||||
ON T.role = ${roleField} and R.scope = '${scope}'`;
|
||||
const where = `WHERE ${tableName}.${idColumn} = mapping.id${
|
||||
table !== 'user' ? ` AND ${tableName}.${uidColumn} = mapping.uid` : ''
|
||||
}`;
|
||||
const query = `UPDATE ${tableName}
|
||||
SET ${roleColumn} = mapping.role_id
|
||||
FROM (${subQuery}) as mapping
|
||||
${where}`;
|
||||
await runQuery(query);
|
||||
|
||||
await addNotNull(table, roleColumnName);
|
||||
await addForeignKey(
|
||||
table,
|
||||
roleColumnName,
|
||||
['role', 'id'],
|
||||
`FK_${tablePrefix}${foreignKeySuffixes[table]}`,
|
||||
);
|
||||
|
||||
await dropColumns(table, ['role']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
|
||||
|
||||
export class RemoveFailedExecutionStatus1711018413374 implements IrreversibleMigration {
|
||||
async up({ escape, runQuery }: MigrationContext) {
|
||||
const executionEntity = escape.tableName('execution_entity');
|
||||
|
||||
await runQuery(`UPDATE ${executionEntity} SET status = 'error' WHERE status = 'failed';`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Container } from '@n8n/di';
|
||||
import { Cipher, InstanceSettings } from 'n8n-core';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import { readFile, writeFile, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
/**
|
||||
* Move SSH key pair from file system to database, to enable SSH connections
|
||||
* when running n8n in multiple containers - mains, webhooks, workers, etc.
|
||||
*/
|
||||
export class MoveSshKeysToDatabase1711390882123 implements ReversibleMigration {
|
||||
private readonly settingsKey = 'features.sourceControl.sshKeys';
|
||||
|
||||
private readonly privateKeyPath = path.join(
|
||||
Container.get(InstanceSettings).n8nFolder,
|
||||
'ssh',
|
||||
'key',
|
||||
);
|
||||
|
||||
private readonly publicKeyPath = this.privateKeyPath + '.pub';
|
||||
|
||||
private readonly cipher = Container.get(Cipher);
|
||||
|
||||
async up({ escape, runQuery, logger, migrationName }: MigrationContext) {
|
||||
let privateKey, publicKey;
|
||||
|
||||
try {
|
||||
[privateKey, publicKey] = await Promise.all([
|
||||
readFile(this.privateKeyPath, { encoding: 'utf8' }),
|
||||
readFile(this.publicKeyPath, { encoding: 'utf8' }),
|
||||
]);
|
||||
} catch {
|
||||
logger.info(`[${migrationName}] No SSH keys in filesystem, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!privateKey && !publicKey) {
|
||||
logger.info(`[${migrationName}] No SSH keys in filesystem, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = escape.tableName('settings');
|
||||
const key = escape.columnName('key');
|
||||
const value = escape.columnName('value');
|
||||
|
||||
const rows: Array<{ value: string }> = await runQuery(
|
||||
`SELECT value FROM ${settings} WHERE ${key} = '${this.settingsKey}';`,
|
||||
);
|
||||
|
||||
if (rows.length === 1) {
|
||||
logger.info(`[${migrationName}] SSH keys already in database, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!privateKey) {
|
||||
logger.error(`[${migrationName}] No private key found, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const settingsValue = JSON.stringify({
|
||||
encryptedPrivateKey: this.cipher.encrypt(privateKey),
|
||||
publicKey,
|
||||
});
|
||||
|
||||
await runQuery(
|
||||
`INSERT INTO ${settings} (${key}, ${value}) VALUES ('${this.settingsKey}', '${settingsValue}');`,
|
||||
);
|
||||
|
||||
try {
|
||||
await Promise.all([rm(this.privateKeyPath), rm(this.publicKeyPath)]);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
const error = e instanceof Error ? e : new Error(`${e}`);
|
||||
logger.error(
|
||||
`[${migrationName}] Failed to remove SSH keys from filesystem: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async down({ escape, runQuery, logger, migrationName }: MigrationContext) {
|
||||
const settings = escape.tableName('settings');
|
||||
const key = escape.columnName('key');
|
||||
|
||||
const rows: Array<{ value: string }> = await runQuery(
|
||||
`SELECT value FROM ${settings} WHERE ${key} = '${this.settingsKey}';`,
|
||||
);
|
||||
|
||||
if (rows.length !== 1) {
|
||||
logger.info(`[${migrationName}] No SSH keys in database, skipping revert`);
|
||||
return;
|
||||
}
|
||||
|
||||
const [row] = rows;
|
||||
|
||||
type KeyPair = { publicKey: string; encryptedPrivateKey: string };
|
||||
|
||||
const dbKeyPair = jsonParse<KeyPair | null>(row.value, { fallbackValue: null });
|
||||
|
||||
if (!dbKeyPair) {
|
||||
logger.info(`[${migrationName}] Malformed SSH keys in database, skipping revert`);
|
||||
return;
|
||||
}
|
||||
|
||||
const privateKey = this.cipher.decrypt(dbKeyPair.encryptedPrivateKey);
|
||||
const { publicKey } = dbKeyPair;
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
writeFile(this.privateKeyPath, privateKey, { encoding: 'utf8', mode: 0o600 }),
|
||||
writeFile(this.publicKeyPath, publicKey, { encoding: 'utf8', mode: 0o600 }),
|
||||
]);
|
||||
} catch {
|
||||
logger.error(`[${migrationName}] Failed to write SSH keys to filesystem, skipping revert`);
|
||||
return;
|
||||
}
|
||||
|
||||
await runQuery(`DELETE FROM ${settings} WHERE ${key} = 'features.sourceControl.sshKeys';`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
|
||||
|
||||
export class RemoveNodesAccess1712044305787 implements IrreversibleMigration {
|
||||
async up({ schemaBuilder: { dropColumns } }: MigrationContext) {
|
||||
await dropColumns('credentials_entity', ['nodesAccess']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import type { ProjectRole } from '@n8n/permissions';
|
||||
import { generateNanoId } from '@n8n/utils';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
import type { User } from '../../entities';
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const projectAdminRole: ProjectRole = 'project:personalOwner';
|
||||
|
||||
type RelationTable = 'shared_workflow' | 'shared_credentials';
|
||||
|
||||
const table = {
|
||||
sharedCredentials: 'shared_credentials',
|
||||
sharedCredentialsTemp: 'shared_credentials_2',
|
||||
sharedWorkflow: 'shared_workflow',
|
||||
sharedWorkflowTemp: 'shared_workflow_2',
|
||||
project: 'project',
|
||||
user: 'user',
|
||||
projectRelation: 'project_relation',
|
||||
} as const;
|
||||
|
||||
function escapeNames(escape: MigrationContext['escape']) {
|
||||
const t = {
|
||||
project: escape.tableName(table.project),
|
||||
projectRelation: escape.tableName(table.projectRelation),
|
||||
sharedCredentials: escape.tableName(table.sharedCredentials),
|
||||
sharedCredentialsTemp: escape.tableName(table.sharedCredentialsTemp),
|
||||
sharedWorkflow: escape.tableName(table.sharedWorkflow),
|
||||
sharedWorkflowTemp: escape.tableName(table.sharedWorkflowTemp),
|
||||
user: escape.tableName(table.user),
|
||||
};
|
||||
const c = {
|
||||
createdAt: escape.columnName('createdAt'),
|
||||
updatedAt: escape.columnName('updatedAt'),
|
||||
workflowId: escape.columnName('workflowId'),
|
||||
credentialsId: escape.columnName('credentialsId'),
|
||||
userId: escape.columnName('userId'),
|
||||
projectId: escape.columnName('projectId'),
|
||||
firstName: escape.columnName('firstName'),
|
||||
lastName: escape.columnName('lastName'),
|
||||
};
|
||||
|
||||
return { t, c };
|
||||
}
|
||||
|
||||
export class CreateProject1714133768519 implements ReversibleMigration {
|
||||
async setupTables({ schemaBuilder: { createTable, column } }: MigrationContext) {
|
||||
await createTable(table.project).withColumns(
|
||||
column('id').varchar(36).primary.notNull,
|
||||
column('name').varchar(255).notNull,
|
||||
column('type').varchar(36).notNull,
|
||||
).withTimestamps;
|
||||
|
||||
await createTable(table.projectRelation)
|
||||
.withColumns(
|
||||
column('projectId').varchar(36).primary.notNull,
|
||||
column('userId').uuid.primary.notNull,
|
||||
column('role').varchar().notNull,
|
||||
)
|
||||
.withIndexOn('projectId')
|
||||
.withIndexOn('userId')
|
||||
.withForeignKey('projectId', {
|
||||
tableName: table.project,
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
.withForeignKey('userId', {
|
||||
tableName: 'user',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
}).withTimestamps;
|
||||
}
|
||||
|
||||
async alterSharedTable(
|
||||
relationTableName: RelationTable,
|
||||
{
|
||||
escape,
|
||||
runQuery,
|
||||
schemaBuilder: { addForeignKey, addColumns, addNotNull, createIndex, column },
|
||||
}: MigrationContext,
|
||||
) {
|
||||
const projectIdColumn = column('projectId').varchar(36).default('NULL');
|
||||
await addColumns(relationTableName, [projectIdColumn]);
|
||||
|
||||
const relationTable = escape.tableName(relationTableName);
|
||||
const { t, c } = escapeNames(escape);
|
||||
|
||||
// Populate projectId
|
||||
const subQuery = `
|
||||
SELECT P.id as ${c.projectId}, T.${c.userId}
|
||||
FROM ${t.projectRelation} T
|
||||
LEFT JOIN ${t.project} P
|
||||
ON T.${c.projectId} = P.id AND P.type = 'personal'
|
||||
LEFT JOIN ${relationTable} S
|
||||
ON T.${c.userId} = S.${c.userId}
|
||||
WHERE P.id IS NOT NULL
|
||||
`;
|
||||
const swQuery = `UPDATE ${relationTable}
|
||||
SET ${c.projectId} = mapping.${c.projectId}
|
||||
FROM (${subQuery}) as mapping
|
||||
WHERE ${relationTable}.${c.userId} = mapping.${c.userId}`;
|
||||
|
||||
await runQuery(swQuery);
|
||||
|
||||
await addForeignKey(relationTableName, 'projectId', ['project', 'id']);
|
||||
|
||||
await addNotNull(relationTableName, 'projectId');
|
||||
|
||||
// Index the new projectId column
|
||||
await createIndex(relationTableName, ['projectId']);
|
||||
}
|
||||
|
||||
async alterSharedCredentials({
|
||||
escape,
|
||||
runQuery,
|
||||
schemaBuilder: { column, createTable, dropTable },
|
||||
}: MigrationContext) {
|
||||
await createTable(table.sharedCredentialsTemp)
|
||||
.withColumns(
|
||||
column('credentialsId').varchar(36).notNull.primary,
|
||||
column('projectId').varchar(36).notNull.primary,
|
||||
column('role').text.notNull,
|
||||
)
|
||||
.withForeignKey('credentialsId', {
|
||||
tableName: 'credentials_entity',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
.withForeignKey('projectId', {
|
||||
tableName: table.project,
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
}).withTimestamps;
|
||||
|
||||
const { c, t } = escapeNames(escape);
|
||||
|
||||
await runQuery(`
|
||||
INSERT INTO ${t.sharedCredentialsTemp} (${c.createdAt}, ${c.updatedAt}, ${c.credentialsId}, ${c.projectId}, role)
|
||||
SELECT ${c.createdAt}, ${c.updatedAt}, ${c.credentialsId}, ${c.projectId}, role FROM ${t.sharedCredentials};
|
||||
`);
|
||||
|
||||
await dropTable(table.sharedCredentials);
|
||||
await runQuery(`ALTER TABLE ${t.sharedCredentialsTemp} RENAME TO ${t.sharedCredentials};`);
|
||||
}
|
||||
|
||||
async alterSharedWorkflow({
|
||||
escape,
|
||||
runQuery,
|
||||
schemaBuilder: { column, createTable, dropTable },
|
||||
}: MigrationContext) {
|
||||
await createTable(table.sharedWorkflowTemp)
|
||||
.withColumns(
|
||||
column('workflowId').varchar(36).notNull.primary,
|
||||
column('projectId').varchar(36).notNull.primary,
|
||||
column('role').text.notNull,
|
||||
)
|
||||
.withForeignKey('workflowId', {
|
||||
tableName: 'workflow_entity',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
.withForeignKey('projectId', {
|
||||
tableName: table.project,
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
}).withTimestamps;
|
||||
|
||||
const { c, t } = escapeNames(escape);
|
||||
|
||||
await runQuery(`
|
||||
INSERT INTO ${t.sharedWorkflowTemp} (${c.createdAt}, ${c.updatedAt}, ${c.workflowId}, ${c.projectId}, role)
|
||||
SELECT ${c.createdAt}, ${c.updatedAt}, ${c.workflowId}, ${c.projectId}, role FROM ${t.sharedWorkflow};
|
||||
`);
|
||||
|
||||
await dropTable(table.sharedWorkflow);
|
||||
await runQuery(`ALTER TABLE ${t.sharedWorkflowTemp} RENAME TO ${t.sharedWorkflow};`);
|
||||
}
|
||||
|
||||
async createUserPersonalProjects({ runQuery, runInBatches, escape }: MigrationContext) {
|
||||
const { c, t } = escapeNames(escape);
|
||||
const getUserQuery = `SELECT id, ${c.firstName}, ${c.lastName}, email FROM ${t.user}`;
|
||||
await runInBatches<Pick<User, 'id' | 'firstName' | 'lastName' | 'email'>>(
|
||||
getUserQuery,
|
||||
async (users) => {
|
||||
await Promise.all(
|
||||
users.map(async (user) => {
|
||||
const projectId = generateNanoId();
|
||||
const name = this.createPersonalProjectName(user.firstName, user.lastName, user.email);
|
||||
await runQuery(
|
||||
`INSERT INTO ${t.project} (id, type, name) VALUES (:projectId, 'personal', :name)`,
|
||||
{
|
||||
projectId,
|
||||
name,
|
||||
},
|
||||
);
|
||||
|
||||
await runQuery(
|
||||
`INSERT INTO ${t.projectRelation} (${c.projectId}, ${c.userId}, role) VALUES (:projectId, :userId, :projectRole)`,
|
||||
{
|
||||
projectId,
|
||||
userId: user.id,
|
||||
projectRole: projectAdminRole,
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Duplicated from packages/@n8n/db/src/entities/User.ts
|
||||
// Reason:
|
||||
// This migration should work the same even if we refactor the function in
|
||||
// `User.ts`.
|
||||
createPersonalProjectName(firstName?: string, lastName?: string, email?: string) {
|
||||
if (firstName && lastName && email) {
|
||||
return `${firstName} ${lastName} <${email}>`;
|
||||
} else if (email) {
|
||||
return `<${email}>`;
|
||||
} else {
|
||||
return 'Unnamed Project';
|
||||
}
|
||||
}
|
||||
|
||||
async up(context: MigrationContext) {
|
||||
await this.setupTables(context);
|
||||
await this.createUserPersonalProjects(context);
|
||||
await this.alterSharedTable(table.sharedCredentials, context);
|
||||
await this.alterSharedCredentials(context);
|
||||
await this.alterSharedTable(table.sharedWorkflow, context);
|
||||
await this.alterSharedWorkflow(context);
|
||||
}
|
||||
|
||||
async down({ logger, escape, runQuery, schemaBuilder: sb }: MigrationContext) {
|
||||
const { t, c } = escapeNames(escape);
|
||||
|
||||
// 0. check if all projects are personal projects
|
||||
const [{ count: nonPersonalProjects }] = await runQuery<[{ count: number }]>(
|
||||
`SELECT COUNT(*) FROM ${t.project} WHERE type <> 'personal';`,
|
||||
);
|
||||
|
||||
if (nonPersonalProjects > 0) {
|
||||
const message =
|
||||
'Down migration only possible when there are no projects. Please delete all projects that were created via the UI first.';
|
||||
logger.error(message);
|
||||
throw new UserError(message);
|
||||
}
|
||||
|
||||
// 1. create temp table for shared workflows
|
||||
await sb
|
||||
.createTable(table.sharedWorkflowTemp)
|
||||
.withColumns(
|
||||
sb.column('workflowId').varchar(36).notNull.primary,
|
||||
sb.column('userId').uuid.notNull.primary,
|
||||
sb.column('role').text.notNull,
|
||||
)
|
||||
.withForeignKey('workflowId', {
|
||||
tableName: 'workflow_entity',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
name: undefined,
|
||||
})
|
||||
.withForeignKey('userId', {
|
||||
tableName: table.user,
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
}).withTimestamps;
|
||||
|
||||
// 2. migrate data into temp table
|
||||
await runQuery(`
|
||||
INSERT INTO ${t.sharedWorkflowTemp} (${c.createdAt}, ${c.updatedAt}, ${c.workflowId}, role, ${c.userId})
|
||||
SELECT SW.${c.createdAt}, SW.${c.updatedAt}, SW.${c.workflowId}, SW.role, PR.${c.userId}
|
||||
FROM ${t.sharedWorkflow} SW
|
||||
LEFT JOIN project_relation PR on SW.${c.projectId} = PR.${c.projectId} AND PR.role = 'project:personalOwner'
|
||||
`);
|
||||
|
||||
// 3. drop shared workflow table
|
||||
await sb.dropTable(table.sharedWorkflow);
|
||||
|
||||
// 4. rename temp table
|
||||
await runQuery(`ALTER TABLE ${t.sharedWorkflowTemp} RENAME TO ${t.sharedWorkflow};`);
|
||||
|
||||
// 5. same for shared creds
|
||||
await sb
|
||||
.createTable(table.sharedCredentialsTemp)
|
||||
.withColumns(
|
||||
sb.column('credentialsId').varchar(36).notNull.primary,
|
||||
sb.column('userId').uuid.notNull.primary,
|
||||
sb.column('role').text.notNull,
|
||||
)
|
||||
.withForeignKey('credentialsId', {
|
||||
tableName: 'credentials_entity',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
name: undefined,
|
||||
})
|
||||
.withForeignKey('userId', {
|
||||
tableName: table.user,
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
}).withTimestamps;
|
||||
await runQuery(`
|
||||
INSERT INTO ${t.sharedCredentialsTemp} (${c.createdAt}, ${c.updatedAt}, ${c.credentialsId}, role, ${c.userId})
|
||||
SELECT SC.${c.createdAt}, SC.${c.updatedAt}, SC.${c.credentialsId}, SC.role, PR.${c.userId}
|
||||
FROM ${t.sharedCredentials} SC
|
||||
LEFT JOIN project_relation PR on SC.${c.projectId} = PR.${c.projectId} AND PR.role = 'project:personalOwner'
|
||||
`);
|
||||
await sb.dropTable(table.sharedCredentials);
|
||||
await runQuery(`ALTER TABLE ${t.sharedCredentialsTemp} RENAME TO ${t.sharedCredentials};`);
|
||||
|
||||
// 6. drop project and project relation table
|
||||
await sb.dropTable(table.projectRelation);
|
||||
await sb.dropTable(table.project);
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
|
||||
|
||||
export class MakeExecutionStatusNonNullable1714133768521 implements IrreversibleMigration {
|
||||
async up({ escape, runQuery, schemaBuilder }: MigrationContext) {
|
||||
const executionEntity = escape.tableName('execution_entity');
|
||||
const status = escape.columnName('status');
|
||||
const finished = escape.columnName('finished');
|
||||
|
||||
const query = `
|
||||
UPDATE ${executionEntity}
|
||||
SET ${status} = CASE
|
||||
WHEN ${finished} = true THEN 'success'
|
||||
WHEN ${finished} = false THEN 'error'
|
||||
END
|
||||
WHERE ${status} IS NULL;
|
||||
`;
|
||||
|
||||
await runQuery(query);
|
||||
|
||||
await schemaBuilder.addNotNull('execution_entity', 'status');
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
export class AddConstraintToExecutionMetadata1720101653148 implements ReversibleMigration {
|
||||
async up(context: MigrationContext) {
|
||||
const { createTable, dropTable, column } = context.schemaBuilder;
|
||||
const { escape } = context;
|
||||
|
||||
const executionMetadataTableRaw = 'execution_metadata';
|
||||
const executionMetadataTable = escape.tableName(executionMetadataTableRaw);
|
||||
const executionMetadataTableTempRaw = 'execution_metadata_temp';
|
||||
const executionMetadataTableTemp = escape.tableName(executionMetadataTableTempRaw);
|
||||
const id = escape.columnName('id');
|
||||
const executionId = escape.columnName('executionId');
|
||||
const key = escape.columnName('key');
|
||||
const value = escape.columnName('value');
|
||||
|
||||
await createTable(executionMetadataTableTempRaw)
|
||||
.withColumns(
|
||||
column('id').int.notNull.primary.autoGenerate,
|
||||
column('executionId').int.notNull,
|
||||
column('key').varchar(255).notNull,
|
||||
column('value').text.notNull,
|
||||
)
|
||||
.withForeignKey('executionId', {
|
||||
tableName: 'execution_entity',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
name: undefined,
|
||||
})
|
||||
.withIndexOn(['executionId', 'key'], true);
|
||||
|
||||
await context.runQuery(`
|
||||
INSERT INTO ${executionMetadataTableTemp} (${id}, ${executionId}, ${key}, ${value})
|
||||
SELECT MAX(${id}) as ${id}, ${executionId}, ${key}, MAX(${value})
|
||||
FROM ${executionMetadataTable}
|
||||
GROUP BY ${executionId}, ${key}
|
||||
ON CONFLICT (${executionId}, ${key}) DO UPDATE SET
|
||||
id = EXCLUDED.id,
|
||||
value = EXCLUDED.value
|
||||
WHERE EXCLUDED.id > ${executionMetadataTableTemp}.id;
|
||||
`);
|
||||
|
||||
await dropTable(executionMetadataTableRaw);
|
||||
await context.runQuery(
|
||||
`ALTER TABLE ${executionMetadataTableTemp} RENAME TO ${executionMetadataTable};`,
|
||||
);
|
||||
}
|
||||
|
||||
async down(context: MigrationContext) {
|
||||
const { createTable, dropTable, column } = context.schemaBuilder;
|
||||
const { escape } = context;
|
||||
|
||||
const executionMetadataTableRaw = 'execution_metadata';
|
||||
const executionMetadataTable = escape.tableName(executionMetadataTableRaw);
|
||||
const executionMetadataTableTempRaw = 'execution_metadata_temp';
|
||||
const executionMetadataTableTemp = escape.tableName(executionMetadataTableTempRaw);
|
||||
const id = escape.columnName('id');
|
||||
const executionId = escape.columnName('executionId');
|
||||
const key = escape.columnName('key');
|
||||
const value = escape.columnName('value');
|
||||
|
||||
await createTable(executionMetadataTableTempRaw)
|
||||
.withColumns(
|
||||
// INFO: The PK names that TypeORM creates are predictable and thus it
|
||||
// will create a PK name which already exists in the current
|
||||
// execution_metadata table. That's why we have to randomize the PK name
|
||||
// here.
|
||||
column('id').int.notNull.primaryWithName(nanoid()).autoGenerate,
|
||||
column('executionId').int.notNull,
|
||||
column('key').text.notNull,
|
||||
column('value').text.notNull,
|
||||
)
|
||||
.withForeignKey('executionId', {
|
||||
tableName: 'execution_entity',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
name: undefined,
|
||||
});
|
||||
|
||||
await context.runQuery(`
|
||||
INSERT INTO ${executionMetadataTableTemp} (${id}, ${executionId}, ${key}, ${value})
|
||||
SELECT ${id}, ${executionId}, ${key}, ${value} FROM ${executionMetadataTable};
|
||||
`);
|
||||
|
||||
await dropTable(executionMetadataTableRaw);
|
||||
await context.runQuery(
|
||||
`ALTER TABLE ${executionMetadataTableTemp} RENAME TO ${executionMetadataTable};`,
|
||||
);
|
||||
|
||||
if (context.dbType === 'postgresdb') {
|
||||
// Update sequence so that inserts continue with the next highest id.
|
||||
const tableName = escape.tableName('execution_metadata');
|
||||
const sequenceName = escape.tableName('execution_metadata_temp_id_seq1');
|
||||
|
||||
await context.runQuery(
|
||||
`SELECT setval('${sequenceName}', (SELECT MAX(id) FROM ${tableName}));`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const tableName = 'invalid_auth_token';
|
||||
|
||||
export class CreateInvalidAuthTokenTable1723627610222 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
|
||||
await createTable(tableName).withColumns(
|
||||
column('token').varchar(512).primary,
|
||||
column('expiresAt').timestamp().notNull,
|
||||
);
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
|
||||
await dropTable(tableName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
/**
|
||||
* Add new indices:
|
||||
*
|
||||
* - `workflowId, startedAt` for `ExecutionRepository.findManyByRangeQuery` (default query) and for `ExecutionRepository.findManyByRangeQuery` (filter query)
|
||||
* - `waitTill, status, deletedAt` for `ExecutionRepository.getWaitingExecutions`
|
||||
* - `stoppedAt, status, deletedAt` for `ExecutionRepository.softDeletePrunableExecutions`
|
||||
*
|
||||
* Remove unused indices in sqlite:
|
||||
*
|
||||
* - `stoppedAt` (duplicate with different casing)
|
||||
* - `waitTill`
|
||||
* - `status, workflowId`
|
||||
*
|
||||
* Remove unused indices in all DBs:
|
||||
*
|
||||
* - `waitTill, id`
|
||||
* - `workflowId, id`
|
||||
*
|
||||
* Remove incomplete index in all DBs:
|
||||
*
|
||||
* - `stopped_at` (replaced with composite index)
|
||||
*
|
||||
* Keep index as is:
|
||||
*
|
||||
* - `deletedAt` for query at `ExecutionRepository.hardDeleteSoftDeletedExecutions`
|
||||
*/
|
||||
export class RefactorExecutionIndices1723796243146 implements ReversibleMigration {
|
||||
async up({ schemaBuilder, isPostgres, isSqlite, runQuery, escape }: MigrationContext) {
|
||||
if (isSqlite || isPostgres) {
|
||||
const executionEntity = escape.tableName('execution_entity');
|
||||
|
||||
const workflowId = escape.columnName('workflowId');
|
||||
const startedAt = escape.columnName('startedAt');
|
||||
const waitTill = escape.columnName('waitTill');
|
||||
const status = escape.columnName('status');
|
||||
const deletedAt = escape.columnName('deletedAt');
|
||||
const stoppedAt = escape.columnName('stoppedAt');
|
||||
|
||||
await runQuery(`
|
||||
CREATE INDEX idx_execution_entity_workflow_id_started_at
|
||||
ON ${executionEntity} (${workflowId}, ${startedAt})
|
||||
WHERE ${startedAt} IS NOT NULL AND ${deletedAt} IS NULL;
|
||||
`);
|
||||
|
||||
await runQuery(`
|
||||
CREATE INDEX idx_execution_entity_wait_till_status_deleted_at
|
||||
ON ${executionEntity} (${waitTill}, ${status}, ${deletedAt})
|
||||
WHERE ${waitTill} IS NOT NULL AND ${deletedAt} IS NULL;
|
||||
`);
|
||||
|
||||
await runQuery(`
|
||||
CREATE INDEX idx_execution_entity_stopped_at_status_deleted_at
|
||||
ON ${executionEntity} (${stoppedAt}, ${status}, ${deletedAt})
|
||||
WHERE ${stoppedAt} IS NOT NULL AND ${deletedAt} IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
if (isSqlite) {
|
||||
await schemaBuilder.dropIndex('execution_entity', ['waitTill'], {
|
||||
customIndexName: 'idx_execution_entity_wait_till',
|
||||
skipIfMissing: true,
|
||||
});
|
||||
|
||||
await schemaBuilder.dropIndex('execution_entity', ['status', 'workflowId'], {
|
||||
customIndexName: 'IDX_8b6f3f9ae234f137d707b98f3bf43584',
|
||||
skipIfMissing: true,
|
||||
});
|
||||
}
|
||||
|
||||
// all DBs
|
||||
|
||||
await schemaBuilder.dropIndex(
|
||||
'execution_entity',
|
||||
['stoppedAt'],
|
||||
isSqlite ? { customIndexName: 'idx_execution_entity_stopped_at', skipIfMissing: true } : {},
|
||||
);
|
||||
await schemaBuilder.dropIndex('execution_entity', ['waitTill', 'id'], {
|
||||
customIndexName: isPostgres
|
||||
? 'IDX_85b981df7b444f905f8bf50747'
|
||||
: 'IDX_b94b45ce2c73ce46c54f20b5f9',
|
||||
skipIfMissing: true,
|
||||
});
|
||||
await schemaBuilder.dropIndex('execution_entity', ['workflowId', 'id'], {
|
||||
customIndexName: isPostgres
|
||||
? 'idx_execution_entity_workflow_id_id'
|
||||
: 'IDX_81fc04c8a17de15835713505e4',
|
||||
skipIfMissing: true,
|
||||
});
|
||||
}
|
||||
|
||||
async down({ schemaBuilder }: MigrationContext) {
|
||||
await schemaBuilder.dropIndex('execution_entity', ['workflowId', 'startedAt']);
|
||||
await schemaBuilder.dropIndex('execution_entity', ['waitTill', 'status']);
|
||||
await schemaBuilder.dropIndex('execution_entity', ['stoppedAt', 'deletedAt', 'status']);
|
||||
|
||||
await schemaBuilder.createIndex('execution_entity', ['waitTill', 'id']);
|
||||
await schemaBuilder.createIndex('execution_entity', ['stoppedAt']);
|
||||
await schemaBuilder.createIndex('execution_entity', ['workflowId', 'id']);
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const annotationsTableName = 'execution_annotations';
|
||||
const annotationTagsTableName = 'annotation_tag_entity';
|
||||
const annotationTagMappingsTableName = 'execution_annotation_tags';
|
||||
|
||||
export class CreateAnnotationTables1724753530828 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
|
||||
await createTable(annotationsTableName)
|
||||
.withColumns(
|
||||
column('id').int.notNull.primary.autoGenerate,
|
||||
column('executionId').int.notNull,
|
||||
column('vote').varchar(6),
|
||||
column('note').text,
|
||||
)
|
||||
.withIndexOn('executionId', true)
|
||||
.withForeignKey('executionId', {
|
||||
tableName: 'execution_entity',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
}).withTimestamps;
|
||||
|
||||
await createTable(annotationTagsTableName)
|
||||
.withColumns(column('id').varchar(16).primary.notNull, column('name').varchar(24).notNull)
|
||||
.withIndexOn('name', true).withTimestamps;
|
||||
|
||||
await createTable(annotationTagMappingsTableName)
|
||||
.withColumns(
|
||||
column('annotationId').int.notNull.primary,
|
||||
column('tagId').varchar(24).notNull.primary,
|
||||
)
|
||||
.withForeignKey('annotationId', {
|
||||
tableName: annotationsTableName,
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
.withIndexOn('tagId')
|
||||
.withIndexOn('annotationId')
|
||||
.withForeignKey('tagId', {
|
||||
tableName: annotationTagsTableName,
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
|
||||
await dropTable(annotationTagMappingsTableName);
|
||||
await dropTable(annotationTagsTableName);
|
||||
await dropTable(annotationsTableName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { generateNanoId } from '@n8n/utils';
|
||||
|
||||
import type { ApiKey } from '../../entities';
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
export class AddApiKeysTable1724951148974 implements ReversibleMigration {
|
||||
async up({
|
||||
queryRunner,
|
||||
escape,
|
||||
runQuery,
|
||||
schemaBuilder: { createTable, column },
|
||||
}: MigrationContext) {
|
||||
const userTable = escape.tableName('user');
|
||||
const userApiKeysTable = escape.tableName('user_api_keys');
|
||||
const userIdColumn = escape.columnName('userId');
|
||||
const apiKeyColumn = escape.columnName('apiKey');
|
||||
const labelColumn = escape.columnName('label');
|
||||
const idColumn = escape.columnName('id');
|
||||
|
||||
// Create the new table
|
||||
await createTable('user_api_keys')
|
||||
.withColumns(
|
||||
column('id').varchar(36).primary,
|
||||
column('userId').uuid.notNull,
|
||||
column('label').varchar(100).notNull,
|
||||
column('apiKey').varchar().notNull,
|
||||
)
|
||||
.withForeignKey('userId', {
|
||||
tableName: 'user',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
.withIndexOn(['userId', 'label'], true)
|
||||
.withIndexOn(['apiKey'], true).withTimestamps;
|
||||
|
||||
const usersWithApiKeys = (await queryRunner.query(
|
||||
`SELECT ${idColumn}, ${apiKeyColumn} FROM ${userTable} WHERE ${apiKeyColumn} IS NOT NULL`,
|
||||
)) as Array<Partial<ApiKey>>;
|
||||
|
||||
// Move the apiKey from the users table to the new table
|
||||
await Promise.all(
|
||||
usersWithApiKeys.map(
|
||||
async (user: { id: string; apiKey: string }) =>
|
||||
await runQuery(
|
||||
`INSERT INTO ${userApiKeysTable} (${idColumn}, ${userIdColumn}, ${apiKeyColumn}, ${labelColumn}) VALUES (:id, :userId, :apiKey, :label)`,
|
||||
{
|
||||
id: generateNanoId(),
|
||||
userId: user.id,
|
||||
apiKey: user.apiKey,
|
||||
label: 'My API Key',
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Drop apiKey column on user's table
|
||||
await queryRunner.query(`ALTER TABLE ${userTable} DROP COLUMN ${apiKeyColumn};`);
|
||||
}
|
||||
|
||||
async down({
|
||||
queryRunner,
|
||||
runQuery,
|
||||
schemaBuilder: { dropTable, addColumns, createIndex, column },
|
||||
escape,
|
||||
}: MigrationContext) {
|
||||
const userTable = escape.tableName('user');
|
||||
const userApiKeysTable = escape.tableName('user_api_keys');
|
||||
const apiKeyColumn = escape.columnName('apiKey');
|
||||
const userIdColumn = escape.columnName('userId');
|
||||
const idColumn = escape.columnName('id');
|
||||
const createdAtColumn = escape.columnName('createdAt');
|
||||
|
||||
await addColumns('user', [column('apiKey').varchar()]);
|
||||
|
||||
await createIndex('user', ['apiKey'], true);
|
||||
|
||||
const queryToGetUsersApiKeys = `
|
||||
SELECT DISTINCT ON
|
||||
(${userIdColumn}) ${userIdColumn},
|
||||
${apiKeyColumn}, ${createdAtColumn}
|
||||
FROM ${userApiKeysTable}
|
||||
ORDER BY ${userIdColumn}, ${createdAtColumn} ASC;`;
|
||||
|
||||
const oldestApiKeysPerUser = (await queryRunner.query(queryToGetUsersApiKeys)) as Array<
|
||||
Partial<ApiKey>
|
||||
>;
|
||||
|
||||
await Promise.all(
|
||||
oldestApiKeysPerUser.map(
|
||||
async (user: { userId: string; apiKey: string }) =>
|
||||
await runQuery(
|
||||
`UPDATE ${userTable} SET ${apiKeyColumn} = :apiKey WHERE ${idColumn} = :userId`,
|
||||
user,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await dropTable('user_api_keys');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const processedDataTableName = 'processed_data';
|
||||
|
||||
export class CreateProcessedDataTable1726606152711 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
|
||||
await createTable(processedDataTableName)
|
||||
.withColumns(
|
||||
column('workflowId').varchar(36).notNull.primary,
|
||||
column('value').varchar(255).notNull,
|
||||
column('context').varchar(255).notNull.primary,
|
||||
)
|
||||
.withForeignKey('workflowId', {
|
||||
tableName: 'workflow_entity',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
}).withTimestamps;
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
|
||||
await dropTable(processedDataTableName);
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
export class SeparateExecutionCreationFromStart1727427440136 implements ReversibleMigration {
|
||||
async up({
|
||||
schemaBuilder: { addColumns, column, dropNotNull },
|
||||
runQuery,
|
||||
escape,
|
||||
}: MigrationContext) {
|
||||
await addColumns('execution_entity', [
|
||||
column('createdAt').notNull.timestamp().default('NOW()'),
|
||||
]);
|
||||
|
||||
await dropNotNull('execution_entity', 'startedAt');
|
||||
|
||||
const executionEntity = escape.tableName('execution_entity');
|
||||
const createdAt = escape.columnName('createdAt');
|
||||
const startedAt = escape.columnName('startedAt');
|
||||
|
||||
// inaccurate for pre-migration rows but prevents `createdAt` from being nullable
|
||||
await runQuery(`UPDATE ${executionEntity} SET ${createdAt} = ${startedAt};`);
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropColumns, addNotNull } }: MigrationContext) {
|
||||
await dropColumns('execution_entity', ['createdAt']);
|
||||
await addNotNull('execution_entity', 'startedAt');
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert';
|
||||
|
||||
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
|
||||
|
||||
export class AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644
|
||||
implements IrreversibleMigration
|
||||
{
|
||||
async up({ queryRunner, tablePrefix }: MigrationContext) {
|
||||
// Check if the primary key already exists
|
||||
const table = await queryRunner.getTable(`${tablePrefix}execution_annotation_tags`);
|
||||
|
||||
assert(table, 'execution_annotation_tags table not found');
|
||||
|
||||
const hasPrimaryKey = table.primaryColumns.length > 0;
|
||||
|
||||
if (!hasPrimaryKey) {
|
||||
await queryRunner.createPrimaryKey(`${tablePrefix}execution_annotation_tags`, [
|
||||
'annotationId',
|
||||
'tagId',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const processedDataTableName = 'processed_data';
|
||||
export class UpdateProcessedDataValueColumnToText1729607673464 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { addNotNull }, runQuery, tablePrefix }: MigrationContext) {
|
||||
const prefixedTableName = `${tablePrefix}${processedDataTableName}`;
|
||||
await runQuery(`ALTER TABLE ${prefixedTableName} ADD COLUMN value_temp TEXT;`);
|
||||
await runQuery(`UPDATE ${prefixedTableName} SET value_temp = value;`);
|
||||
await runQuery(`ALTER TABLE ${prefixedTableName} DROP COLUMN value;`);
|
||||
|
||||
await runQuery(`ALTER TABLE ${prefixedTableName} RENAME COLUMN value_temp TO value`);
|
||||
await addNotNull(processedDataTableName, 'value');
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { addNotNull }, runQuery, tablePrefix }: MigrationContext) {
|
||||
const prefixedTableName = `${tablePrefix}${processedDataTableName}`;
|
||||
await runQuery(`ALTER TABLE ${prefixedTableName} ADD COLUMN value_temp VARCHAR(255);`);
|
||||
await runQuery(`UPDATE ${prefixedTableName} SET value_temp = value;`);
|
||||
await runQuery(`ALTER TABLE ${prefixedTableName} DROP COLUMN value;`);
|
||||
|
||||
await runQuery(`ALTER TABLE ${prefixedTableName} RENAME COLUMN value_temp TO value`);
|
||||
await addNotNull(processedDataTableName, 'value');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
export class AddProjectIcons1729607673469 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
|
||||
await addColumns('project', [column('icon').json]);
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
|
||||
await dropColumns('project', ['icon']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const testEntityTableName = 'test_definition';
|
||||
|
||||
export class CreateTestDefinitionTable1730386903556 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
|
||||
await createTable(testEntityTableName)
|
||||
.withColumns(
|
||||
column('id').int.notNull.primary.autoGenerate,
|
||||
column('name').varchar(255).notNull,
|
||||
column('workflowId').varchar(36).notNull,
|
||||
column('evaluationWorkflowId').varchar(36),
|
||||
column('annotationTagId').varchar(16),
|
||||
)
|
||||
.withIndexOn('workflowId')
|
||||
.withIndexOn('evaluationWorkflowId')
|
||||
.withForeignKey('workflowId', {
|
||||
tableName: 'workflow_entity',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
.withForeignKey('evaluationWorkflowId', {
|
||||
tableName: 'workflow_entity',
|
||||
columnName: 'id',
|
||||
onDelete: 'SET NULL',
|
||||
})
|
||||
.withForeignKey('annotationTagId', {
|
||||
tableName: 'annotation_tag_entity',
|
||||
columnName: 'id',
|
||||
onDelete: 'SET NULL',
|
||||
}).withTimestamps;
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
|
||||
await dropTable(testEntityTableName);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
export class AddDescriptionToTestDefinition1731404028106 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
|
||||
await addColumns('test_definition', [column('description').text]);
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
|
||||
await dropColumns('test_definition', ['description']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const testMetricEntityTableName = 'test_metric';
|
||||
|
||||
export class CreateTestMetricTable1732271325258 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
|
||||
await createTable(testMetricEntityTableName)
|
||||
.withColumns(
|
||||
column('id').varchar(36).primary.notNull,
|
||||
column('name').varchar(255).notNull,
|
||||
column('testDefinitionId').varchar(36).notNull,
|
||||
)
|
||||
.withIndexOn('testDefinitionId')
|
||||
.withForeignKey('testDefinitionId', {
|
||||
tableName: 'test_definition',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
}).withTimestamps;
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
|
||||
await dropTable(testMetricEntityTableName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const testRunTableName = 'test_run';
|
||||
|
||||
export class CreateTestRun1732549866705 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
|
||||
await createTable(testRunTableName)
|
||||
.withColumns(
|
||||
column('id').varchar(36).primary.notNull,
|
||||
column('testDefinitionId').varchar(36).notNull,
|
||||
column('status').varchar().notNull,
|
||||
column('runAt').timestamp(),
|
||||
column('completedAt').timestamp(),
|
||||
column('metrics').json,
|
||||
)
|
||||
.withIndexOn('testDefinitionId')
|
||||
.withForeignKey('testDefinitionId', {
|
||||
tableName: 'test_definition',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
}).withTimestamps;
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
|
||||
await dropTable(testRunTableName);
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
// We have to use raw query migration instead of schemaBuilder helpers,
|
||||
// because the typeorm schema builder implements addColumns by a table recreate for sqlite
|
||||
// which causes weird issues with the migration
|
||||
export class AddMockedNodesColumnToTestDefinition1733133775640 implements ReversibleMigration {
|
||||
async up({ escape, runQuery }: MigrationContext) {
|
||||
const tableName = escape.tableName('test_definition');
|
||||
const mockedNodesColumnName = escape.columnName('mockedNodes');
|
||||
|
||||
await runQuery(
|
||||
`ALTER TABLE ${tableName} ADD COLUMN ${mockedNodesColumnName} JSON DEFAULT ('[]') NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
async down({ escape, runQuery }: MigrationContext) {
|
||||
const tableName = escape.tableName('test_definition');
|
||||
const columnName = escape.columnName('mockedNodes');
|
||||
|
||||
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`);
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
export class AddManagedColumnToCredentialsTable1734479635324 implements ReversibleMigration {
|
||||
async up({ escape, runQuery, isSqlite }: MigrationContext) {
|
||||
const tableName = escape.tableName('credentials_entity');
|
||||
const columnName = escape.columnName('isManaged');
|
||||
|
||||
const defaultValue = isSqlite ? 0 : 'FALSE';
|
||||
|
||||
await runQuery(
|
||||
`ALTER TABLE ${tableName} ADD COLUMN ${columnName} BOOLEAN NOT NULL DEFAULT ${defaultValue}`,
|
||||
);
|
||||
}
|
||||
|
||||
async down({ escape, runQuery }: MigrationContext) {
|
||||
const tableName = escape.tableName('credentials_entity');
|
||||
const columnName = escape.columnName('isManaged');
|
||||
|
||||
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const columns = ['totalCases', 'passedCases', 'failedCases'] as const;
|
||||
|
||||
export class AddStatsColumnsToTestRun1736172058779 implements ReversibleMigration {
|
||||
async up({ escape, runQuery }: MigrationContext) {
|
||||
const tableName = escape.tableName('test_run');
|
||||
const columnNames = columns.map((name) => escape.columnName(name));
|
||||
|
||||
// Values can be NULL only if the test run is new, otherwise they must be non-negative integers.
|
||||
// Test run might be cancelled or interrupted by unexpected error at any moment, so values can be either NULL or non-negative integers.
|
||||
for (const name of columnNames) {
|
||||
await runQuery(`ALTER TABLE ${tableName} ADD COLUMN ${name} INT CHECK(
|
||||
CASE
|
||||
WHEN status = 'new' THEN ${name} IS NULL
|
||||
WHEN status in ('cancelled', 'error') THEN ${name} IS NULL OR ${name} >= 0
|
||||
ELSE ${name} >= 0
|
||||
END
|
||||
)`);
|
||||
}
|
||||
}
|
||||
|
||||
async down({ escape, runQuery }: MigrationContext) {
|
||||
const tableName = escape.tableName('test_run');
|
||||
const columnNames = columns.map((name) => escape.columnName(name));
|
||||
|
||||
for (const name of columnNames) {
|
||||
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const testCaseExecutionTableName = 'test_case_execution';
|
||||
|
||||
export class CreateTestCaseExecutionTable1736947513045 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
|
||||
await createTable(testCaseExecutionTableName)
|
||||
.withColumns(
|
||||
column('id').varchar(36).primary.notNull,
|
||||
column('testRunId').varchar(36).notNull,
|
||||
column('pastExecutionId').int, // Might be null if execution was deleted after the test run
|
||||
column('executionId').int, // Execution of the workflow under test. Might be null if execution was deleted after the test run
|
||||
column('evaluationExecutionId').int, // Execution of the evaluation workflow. Might be null if execution was deleted after the test run, or if the test run was cancelled
|
||||
column('status').varchar().notNull,
|
||||
column('runAt').timestamp(),
|
||||
column('completedAt').timestamp(),
|
||||
column('errorCode').varchar(),
|
||||
column('errorDetails').json,
|
||||
column('metrics').json,
|
||||
)
|
||||
.withIndexOn('testRunId')
|
||||
.withForeignKey('testRunId', {
|
||||
tableName: 'test_run',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
.withForeignKey('pastExecutionId', {
|
||||
tableName: 'execution_entity',
|
||||
columnName: 'id',
|
||||
onDelete: 'SET NULL',
|
||||
})
|
||||
.withForeignKey('executionId', {
|
||||
tableName: 'execution_entity',
|
||||
columnName: 'id',
|
||||
onDelete: 'SET NULL',
|
||||
})
|
||||
.withForeignKey('evaluationExecutionId', {
|
||||
tableName: 'execution_entity',
|
||||
columnName: 'id',
|
||||
onDelete: 'SET NULL',
|
||||
}).withTimestamps;
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
|
||||
await dropTable(testCaseExecutionTableName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
// We have to use raw query migration instead of schemaBuilder helpers,
|
||||
// because the typeorm schema builder implements addColumns by a table recreate for sqlite
|
||||
// which causes weird issues with the migration
|
||||
export class AddErrorColumnsToTestRuns1737715421462 implements ReversibleMigration {
|
||||
async up({ escape, runQuery }: MigrationContext) {
|
||||
const tableName = escape.tableName('test_run');
|
||||
const errorCodeColumnName = escape.columnName('errorCode');
|
||||
const errorDetailsColumnName = escape.columnName('errorDetails');
|
||||
|
||||
await runQuery(`ALTER TABLE ${tableName} ADD COLUMN ${errorCodeColumnName} VARCHAR(255);`);
|
||||
await runQuery(`ALTER TABLE ${tableName} ADD COLUMN ${errorDetailsColumnName} TEXT;`);
|
||||
}
|
||||
|
||||
async down({ escape, runQuery }: MigrationContext) {
|
||||
const tableName = escape.tableName('test_run');
|
||||
const errorCodeColumnName = escape.columnName('errorCode');
|
||||
const errorDetailsColumnName = escape.columnName('errorDetails');
|
||||
|
||||
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${errorCodeColumnName};`);
|
||||
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${errorDetailsColumnName};`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
export class CreateFolderTable1738709609940 implements ReversibleMigration {
|
||||
async up({ runQuery, escape, schemaBuilder: { createTable, column } }: MigrationContext) {
|
||||
const workflowTable = escape.tableName('workflow_entity');
|
||||
const workflowFolderId = escape.columnName('parentFolderId');
|
||||
const folderTable = escape.tableName('folder');
|
||||
const folderId = escape.columnName('id');
|
||||
|
||||
await createTable('folder')
|
||||
.withColumns(
|
||||
column('id').varchar(36).primary.notNull,
|
||||
column('name').varchar(128).notNull,
|
||||
column('parentFolderId').varchar(36).default(null),
|
||||
column('projectId').varchar(36).notNull,
|
||||
)
|
||||
.withForeignKey('projectId', {
|
||||
tableName: 'project',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
.withForeignKey('parentFolderId', {
|
||||
tableName: 'folder',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
.withIndexOn(['projectId', 'id'], true).withTimestamps;
|
||||
|
||||
await createTable('folder_tag')
|
||||
.withColumns(
|
||||
column('folderId').varchar(36).primary.notNull,
|
||||
column('tagId').varchar(36).primary.notNull,
|
||||
)
|
||||
.withForeignKey('folderId', {
|
||||
tableName: 'folder',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
.withForeignKey('tagId', {
|
||||
tableName: 'tag_entity',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
|
||||
await runQuery(
|
||||
`ALTER TABLE ${workflowTable} ADD COLUMN ${workflowFolderId} VARCHAR(36) DEFAULT NULL REFERENCES ${folderTable}(${folderId}) ON DELETE SET NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
async down({ runQuery, escape, schemaBuilder: { dropTable } }: MigrationContext) {
|
||||
const workflowTable = escape.tableName('workflow_entity');
|
||||
const workflowFolderId = escape.columnName('parentFolderId');
|
||||
|
||||
await runQuery(`ALTER TABLE ${workflowTable} DROP COLUMN ${workflowFolderId}`);
|
||||
|
||||
await dropTable('folder_tag');
|
||||
|
||||
await dropTable('folder');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const names = {
|
||||
// table names
|
||||
t: {
|
||||
analyticsMetadata: 'analytics_metadata',
|
||||
analyticsRaw: 'analytics_raw',
|
||||
analyticsByPeriod: 'analytics_by_period',
|
||||
workflowEntity: 'workflow_entity',
|
||||
project: 'project',
|
||||
},
|
||||
// column names by table
|
||||
c: {
|
||||
analyticsMetadata: {
|
||||
metaId: 'metaId',
|
||||
projectId: 'projectId',
|
||||
workflowId: 'workflowId',
|
||||
},
|
||||
analyticsRaw: {
|
||||
metaId: 'metaId',
|
||||
},
|
||||
analyticsByPeriod: {
|
||||
metaId: 'metaId',
|
||||
type: 'type',
|
||||
periodUnit: 'periodUnit',
|
||||
periodStart: 'periodStart',
|
||||
},
|
||||
project: {
|
||||
id: 'id',
|
||||
},
|
||||
workflowEntity: {
|
||||
id: 'id',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export class CreateAnalyticsTables1739549398681 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
|
||||
await createTable(names.t.analyticsMetadata)
|
||||
.withColumns(
|
||||
column(names.c.analyticsMetadata.metaId).int.primary.autoGenerate2,
|
||||
column(names.c.analyticsMetadata.workflowId).varchar(16),
|
||||
column(names.c.analyticsMetadata.projectId).varchar(36),
|
||||
column('workflowName').varchar(128).notNull,
|
||||
column('projectName').varchar(255).notNull,
|
||||
)
|
||||
.withForeignKey(names.c.analyticsMetadata.workflowId, {
|
||||
tableName: names.t.workflowEntity,
|
||||
columnName: names.c.workflowEntity.id,
|
||||
onDelete: 'SET NULL',
|
||||
})
|
||||
.withForeignKey(names.c.analyticsMetadata.projectId, {
|
||||
tableName: names.t.project,
|
||||
columnName: names.c.project.id,
|
||||
onDelete: 'SET NULL',
|
||||
});
|
||||
|
||||
const typeComment = '0: time_saved_minutes, 1: runtime_milliseconds, 2: success, 3: failure';
|
||||
|
||||
await createTable(names.t.analyticsRaw)
|
||||
.withColumns(
|
||||
column('id').int.primary.autoGenerate2,
|
||||
column(names.c.analyticsRaw.metaId).int.notNull,
|
||||
column('type').int.notNull.comment(typeComment),
|
||||
column('value').int.notNull,
|
||||
column('timestamp').timestampNoTimezone(0).default('CURRENT_TIMESTAMP').notNull,
|
||||
)
|
||||
.withForeignKey(names.c.analyticsRaw.metaId, {
|
||||
tableName: names.t.analyticsMetadata,
|
||||
columnName: names.c.analyticsMetadata.metaId,
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
|
||||
await createTable(names.t.analyticsByPeriod)
|
||||
.withColumns(
|
||||
column('id').int.primary.autoGenerate2,
|
||||
column(names.c.analyticsByPeriod.metaId).int.notNull,
|
||||
column(names.c.analyticsByPeriod.type).int.notNull.comment(typeComment),
|
||||
column('value').int.notNull,
|
||||
column(names.c.analyticsByPeriod.periodUnit).int.notNull.comment(
|
||||
'0: hour, 1: day, 2: week',
|
||||
),
|
||||
column(names.c.analyticsByPeriod.periodStart).timestampNoTimezone(0),
|
||||
)
|
||||
.withForeignKey(names.c.analyticsByPeriod.metaId, {
|
||||
tableName: names.t.analyticsMetadata,
|
||||
columnName: names.c.analyticsMetadata.metaId,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
.withIndexOn(
|
||||
[
|
||||
names.c.analyticsByPeriod.periodStart,
|
||||
names.c.analyticsByPeriod.type,
|
||||
names.c.analyticsByPeriod.periodUnit,
|
||||
names.c.analyticsByPeriod.metaId,
|
||||
],
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
|
||||
await dropTable(names.t.analyticsRaw);
|
||||
await dropTable(names.t.analyticsByPeriod);
|
||||
await dropTable(names.t.analyticsMetadata);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
|
||||
|
||||
const names = {
|
||||
// table names
|
||||
t: {
|
||||
analyticsMetadata: 'analytics_metadata',
|
||||
analyticsRaw: 'analytics_raw',
|
||||
analyticsByPeriod: 'analytics_by_period',
|
||||
|
||||
insightsMetadata: 'insights_metadata',
|
||||
insightsRaw: 'insights_raw',
|
||||
insightsByPeriod: 'insights_by_period',
|
||||
|
||||
workflowEntity: 'workflow_entity',
|
||||
project: 'project',
|
||||
},
|
||||
// column names by table
|
||||
c: {
|
||||
insightsMetadata: {
|
||||
metaId: 'metaId',
|
||||
projectId: 'projectId',
|
||||
workflowId: 'workflowId',
|
||||
},
|
||||
insightsRaw: {
|
||||
metaId: 'metaId',
|
||||
},
|
||||
insightsByPeriod: {
|
||||
metaId: 'metaId',
|
||||
type: 'type',
|
||||
periodUnit: 'periodUnit',
|
||||
periodStart: 'periodStart',
|
||||
},
|
||||
project: {
|
||||
id: 'id',
|
||||
},
|
||||
workflowEntity: {
|
||||
id: 'id',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export class RenameAnalyticsToInsights1741167584277 implements IrreversibleMigration {
|
||||
async up({ schemaBuilder: { createTable, column, dropTable } }: MigrationContext) {
|
||||
// Until the insights feature is released we're dropping the tables instead
|
||||
// of migrating them.
|
||||
await dropTable(names.t.analyticsRaw);
|
||||
await dropTable(names.t.analyticsByPeriod);
|
||||
await dropTable(names.t.analyticsMetadata);
|
||||
|
||||
await createTable(names.t.insightsMetadata)
|
||||
.withColumns(
|
||||
column(names.c.insightsMetadata.metaId).int.primary.autoGenerate2,
|
||||
column(names.c.insightsMetadata.workflowId).varchar(16),
|
||||
column(names.c.insightsMetadata.projectId).varchar(36),
|
||||
column('workflowName').varchar(128).notNull,
|
||||
column('projectName').varchar(255).notNull,
|
||||
)
|
||||
.withForeignKey(names.c.insightsMetadata.workflowId, {
|
||||
tableName: names.t.workflowEntity,
|
||||
columnName: names.c.workflowEntity.id,
|
||||
onDelete: 'SET NULL',
|
||||
})
|
||||
.withForeignKey(names.c.insightsMetadata.projectId, {
|
||||
tableName: names.t.project,
|
||||
columnName: names.c.project.id,
|
||||
onDelete: 'SET NULL',
|
||||
})
|
||||
.withIndexOn(names.c.insightsMetadata.workflowId, true);
|
||||
|
||||
const typeComment = '0: time_saved_minutes, 1: runtime_milliseconds, 2: success, 3: failure';
|
||||
|
||||
await createTable(names.t.insightsRaw)
|
||||
.withColumns(
|
||||
column('id').int.primary.autoGenerate2,
|
||||
column(names.c.insightsRaw.metaId).int.notNull,
|
||||
column('type').int.notNull.comment(typeComment),
|
||||
column('value').int.notNull,
|
||||
column('timestamp').timestampTimezone(0).default('CURRENT_TIMESTAMP').notNull,
|
||||
)
|
||||
.withForeignKey(names.c.insightsRaw.metaId, {
|
||||
tableName: names.t.insightsMetadata,
|
||||
columnName: names.c.insightsMetadata.metaId,
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
|
||||
await createTable(names.t.insightsByPeriod)
|
||||
.withColumns(
|
||||
column('id').int.primary.autoGenerate2,
|
||||
column(names.c.insightsByPeriod.metaId).int.notNull,
|
||||
column(names.c.insightsByPeriod.type).int.notNull.comment(typeComment),
|
||||
column('value').int.notNull,
|
||||
column(names.c.insightsByPeriod.periodUnit).int.notNull.comment('0: hour, 1: day, 2: week'),
|
||||
column(names.c.insightsByPeriod.periodStart)
|
||||
.default('CURRENT_TIMESTAMP')
|
||||
.timestampTimezone(0),
|
||||
)
|
||||
.withForeignKey(names.c.insightsByPeriod.metaId, {
|
||||
tableName: names.t.insightsMetadata,
|
||||
columnName: names.c.insightsMetadata.metaId,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
.withIndexOn(
|
||||
[
|
||||
names.c.insightsByPeriod.periodStart,
|
||||
names.c.insightsByPeriod.type,
|
||||
names.c.insightsByPeriod.periodUnit,
|
||||
names.c.insightsByPeriod.metaId,
|
||||
],
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { GlobalRole } from '@n8n/permissions';
|
||||
import { getApiKeyScopesForRole } from '@n8n/permissions';
|
||||
|
||||
import { GLOBAL_ROLES } from '../../constants';
|
||||
import { ApiKey } from '../../entities';
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
type ApiKeyWithRole = { id: string; role: GlobalRole };
|
||||
|
||||
export class AddScopesColumnToApiKeys1742918400000 implements ReversibleMigration {
|
||||
async up({
|
||||
runQuery,
|
||||
escape,
|
||||
queryRunner,
|
||||
schemaBuilder: { addColumns, column },
|
||||
}: MigrationContext) {
|
||||
await addColumns('user_api_keys', [column('scopes').json]);
|
||||
|
||||
const userApiKeysTable = escape.tableName('user_api_keys');
|
||||
const userTable = escape.tableName('user');
|
||||
const idColumn = escape.columnName('id');
|
||||
const userIdColumn = escape.columnName('userId');
|
||||
const roleColumn = escape.columnName('role');
|
||||
|
||||
const apiKeysWithRoles = await runQuery<ApiKeyWithRole[]>(
|
||||
`SELECT ${userApiKeysTable}.${idColumn} AS id, ${userTable}.${roleColumn} AS role FROM ${userApiKeysTable} JOIN ${userTable} ON ${userTable}.${idColumn} = ${userApiKeysTable}.${userIdColumn}`,
|
||||
);
|
||||
|
||||
for (const { id, role } of apiKeysWithRoles) {
|
||||
const dbRole = GLOBAL_ROLES[role];
|
||||
const scopes = getApiKeyScopesForRole({
|
||||
role: dbRole,
|
||||
});
|
||||
await queryRunner.manager.update(ApiKey, { id }, { scopes });
|
||||
}
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
|
||||
await dropColumns('user_api_keys', ['scopes']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { MigrationContext, IrreversibleMigration } from '../migration-types';
|
||||
|
||||
const testRunTableName = 'test_run';
|
||||
const testCaseExecutionTableName = 'test_case_execution';
|
||||
export class ClearEvaluation1745322634000 implements IrreversibleMigration {
|
||||
async up({
|
||||
schemaBuilder: { dropTable, column, createTable },
|
||||
queryRunner,
|
||||
tablePrefix,
|
||||
isSqlite,
|
||||
isPostgres,
|
||||
}: MigrationContext) {
|
||||
// Drop test_metric, test_definition
|
||||
await dropTable(testCaseExecutionTableName);
|
||||
await dropTable(testRunTableName);
|
||||
await dropTable('test_metric');
|
||||
if (isSqlite) {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS ${tablePrefix}test_definition;`);
|
||||
} else if (isPostgres) {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS ${tablePrefix}test_definition CASCADE;`);
|
||||
}
|
||||
|
||||
await createTable(testRunTableName)
|
||||
.withColumns(
|
||||
column('id').varchar(36).primary.notNull,
|
||||
column('workflowId').varchar(36).notNull,
|
||||
column('status').varchar().notNull,
|
||||
column('errorCode').varchar(),
|
||||
column('errorDetails').json,
|
||||
column('runAt').timestamp(),
|
||||
column('completedAt').timestamp(),
|
||||
column('metrics').json,
|
||||
)
|
||||
.withIndexOn('workflowId')
|
||||
.withForeignKey('workflowId', {
|
||||
tableName: 'workflow_entity',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
}).withTimestamps;
|
||||
|
||||
await createTable(testCaseExecutionTableName)
|
||||
.withColumns(
|
||||
column('id').varchar(36).primary.notNull,
|
||||
column('testRunId').varchar(36).notNull,
|
||||
column('executionId').int, // Execution of the workflow under test. Might be null if execution was deleted after the test run
|
||||
column('status').varchar().notNull,
|
||||
column('runAt').timestamp(),
|
||||
column('completedAt').timestamp(),
|
||||
column('errorCode').varchar(),
|
||||
column('errorDetails').json,
|
||||
column('metrics').json,
|
||||
)
|
||||
.withIndexOn('testRunId')
|
||||
.withForeignKey('testRunId', {
|
||||
tableName: 'test_run',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
.withForeignKey('executionId', {
|
||||
tableName: 'execution_entity',
|
||||
columnName: 'id',
|
||||
onDelete: 'SET NULL',
|
||||
}).withTimestamps;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import type { ReversibleMigration, MigrationContext } from '../migration-types';
|
||||
|
||||
const columnName = 'rootCount';
|
||||
const tableName = 'workflow_statistics';
|
||||
|
||||
export class AddWorkflowStatisticsRootCount1745587087521 implements ReversibleMigration {
|
||||
async up({ escape, runQuery }: MigrationContext) {
|
||||
const escapedTableName = escape.tableName(tableName);
|
||||
const escapedColumnName = escape.columnName(columnName);
|
||||
|
||||
await runQuery(
|
||||
`ALTER TABLE ${escapedTableName} ADD COLUMN ${escapedColumnName} INTEGER DEFAULT 0`,
|
||||
);
|
||||
}
|
||||
|
||||
async down({ escape, runQuery }: MigrationContext) {
|
||||
const escapedTableName = escape.tableName(tableName);
|
||||
const escapedColumnName = escape.columnName(columnName);
|
||||
|
||||
await runQuery(`ALTER TABLE ${escapedTableName} DROP COLUMN ${escapedColumnName}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const columnName = 'isArchived';
|
||||
const tableName = 'workflow_entity';
|
||||
|
||||
export class AddWorkflowArchivedColumn1745934666076 implements ReversibleMigration {
|
||||
async up({ escape, runQuery }: MigrationContext) {
|
||||
const escapedTableName = escape.tableName(tableName);
|
||||
const escapedColumnName = escape.columnName(columnName);
|
||||
|
||||
await runQuery(
|
||||
`ALTER TABLE ${escapedTableName} ADD COLUMN ${escapedColumnName} BOOLEAN NOT NULL DEFAULT FALSE`,
|
||||
);
|
||||
}
|
||||
|
||||
async down({ escape, runQuery }: MigrationContext) {
|
||||
const escapedTableName = escape.tableName(tableName);
|
||||
const escapedColumnName = escape.columnName(columnName);
|
||||
|
||||
await runQuery(`ALTER TABLE ${escapedTableName} DROP COLUMN ${escapedColumnName}`);
|
||||
}
|
||||
}
|
||||
@@ -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})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+11
@@ -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']);
|
||||
}
|
||||
}
|
||||
+46
@@ -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);
|
||||
}
|
||||
}
|
||||
+84
@@ -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;
|
||||
}
|
||||
}
|
||||
+46
@@ -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');
|
||||
}
|
||||
}
|
||||
+19
@@ -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']);
|
||||
}
|
||||
}
|
||||
+99
@@ -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');
|
||||
}
|
||||
}
|
||||
+21
@@ -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}`);
|
||||
}
|
||||
}
|
||||
+20
@@ -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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+222
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
+58
@@ -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);
|
||||
}
|
||||
}
|
||||
+58
@@ -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);
|
||||
}
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
}
|
||||
+48
@@ -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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+38
@@ -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}`);
|
||||
}
|
||||
}
|
||||
+82
@@ -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}`);
|
||||
}
|
||||
}
|
||||
+36
@@ -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);
|
||||
}
|
||||
}
|
||||
+13
@@ -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']);
|
||||
}
|
||||
}
|
||||
+46
@@ -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);
|
||||
}
|
||||
}
|
||||
+32
@@ -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};`);
|
||||
}
|
||||
}
|
||||
+91
@@ -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
Reference in New Issue
Block a user