2024-05-17 01:53:15 -07:00
|
|
|
import type { MigrationContext, ReversibleMigration } from '@db/types';
|
2024-08-27 07:44:32 -07:00
|
|
|
import type { ProjectRole } from '@/databases/entities/project-relation';
|
2024-05-17 01:53:15 -07:00
|
|
|
import type { User } from '@/databases/entities/User';
|
|
|
|
import { generateNanoId } from '@/databases/utils/generators';
|
|
|
|
import { ApplicationError } from 'n8n-workflow';
|
|
|
|
import { nanoid } from 'nanoid';
|
|
|
|
|
|
|
|
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,
|
|
|
|
isMysql,
|
|
|
|
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 = isMysql
|
|
|
|
? `UPDATE ${relationTable}, (${subQuery}) as mapping
|
|
|
|
SET ${relationTable}.${c.projectId} = mapping.${c.projectId}
|
|
|
|
WHERE ${relationTable}.${c.userId} = mapping.${c.userId}`
|
|
|
|
: `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/cli/src/databases/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({ isMysql, 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 ApplicationError(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',
|
|
|
|
// In MySQL foreignKey names must be unique across all tables and
|
|
|
|
// TypeORM creates predictable names based on the columnName.
|
|
|
|
// So the current shared_workflow table's foreignKey for workflowId would
|
|
|
|
// clash with this one if we don't create a random name.
|
|
|
|
name: isMysql ? nanoid() : 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',
|
|
|
|
// In MySQL foreignKey names must be unique across all tables and
|
|
|
|
// TypeORM creates predictable names based on the columnName.
|
|
|
|
// So the current shared_credentials table's foreignKey for credentialsId would
|
|
|
|
// clash with this one if we don't create a random name.
|
|
|
|
name: isMysql ? nanoid() : 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);
|
|
|
|
}
|
|
|
|
}
|