Guard yarn database:migrate:prod (#19008)

## Motivations
A lot of self hosters hands up using the `yarn database:migrated:prod`
either manually or through AI assisted debug while they try to upgrade
an instance while their workspace is still blocked in a previous one
Leading to their whole database permanent corruption

## What happened
Replaced the direct call the the typeorm cli to a command calling it
programmatically, adding a layer of security in case a workspace seems
to be blocked in a previous version than the one just before the one
being installed ( e.g 1.0 when you try to upgrade from 1.1 to 1.2 )

For our cloud we still need a way to bypass this security explaining the
-f flag

## Remark
Centralized this logic and refactored creating new services
`WorkspaceVersionService` and `CoreEngineVersionService` that will
become useful for the upcoming upgrade refactor

Related to https://github.com/twentyhq/twenty-infra/pull/529
This commit is contained in:
Paul Rastoin
2026-03-27 14:39:18 +00:00
committed by GitHub
parent c96c034908
commit 281bb6d783
25 changed files with 401 additions and 216 deletions
@@ -0,0 +1,34 @@
import { rawDataSource } from 'src/database/typeorm/raw/raw.datasource';
export const camelToSnakeCase = (str: string) =>
str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
export const performQuery = async <T = unknown>(
query: string,
consoleDescription: string,
withLog = true,
ignoreAlreadyExistsError = false,
) => {
try {
const result = await rawDataSource.query<T>(query);
if (withLog) {
// oxlint-disable-next-line no-console
console.log(`Performed '${consoleDescription}' successfully`);
}
return result;
} catch (err) {
let message = '';
if (ignoreAlreadyExistsError && `${err}`.includes('already exists')) {
message = `Performed '${consoleDescription}' successfully`;
} else {
message = `Failed to perform '${consoleDescription}': ${err}`;
}
if (withLog) {
// oxlint-disable-next-line no-console
console.error(message);
}
}
};
@@ -0,0 +1,98 @@
import { rawDataSource } from 'src/database/typeorm/raw/raw.datasource';
import { camelToSnakeCase, performQuery } from './setup-db-utils';
rawDataSource
.initialize()
.then(async () => {
await performQuery(
'CREATE SCHEMA IF NOT EXISTS "public"',
'create schema "public"',
);
await performQuery(
'CREATE SCHEMA IF NOT EXISTS "core"',
'create schema "core"',
);
await performQuery(
'CREATE EXTENSION IF NOT EXISTS "uuid-ossp"',
'create extension "uuid-ossp"',
);
await performQuery(
'CREATE EXTENSION IF NOT EXISTS "unaccent"',
'create extension "unaccent"',
);
await performQuery(
`CREATE OR REPLACE FUNCTION public.unaccent_immutable(input text)
RETURNS text
LANGUAGE sql
IMMUTABLE
AS $$
SELECT public.unaccent('public.unaccent'::regdictionary, input)
$$;`,
'create immutable unaccent wrapper function',
);
// We paused the work on FDW
if (process.env.IS_FDW_ENABLED !== 'true') {
return;
}
await performQuery(
'CREATE EXTENSION IF NOT EXISTS "postgres_fdw"',
'create extension "postgres_fdw"',
);
await performQuery(
'CREATE EXTENSION IF NOT EXISTS "wrappers"',
'create extension "wrappers"',
);
await performQuery(
'CREATE EXTENSION IF NOT EXISTS "mysql_fdw"',
'create extension "mysql_fdw"',
);
const supabaseWrappers = [
'airtable',
'bigQuery',
'clickHouse',
'firebase',
'logflare',
's3',
'stripe',
]; // See https://supabase.github.io/wrappers/
for (const wrapper of supabaseWrappers) {
if (await checkForeignDataWrapperExists(`${wrapper.toLowerCase()}_fdw`)) {
continue;
}
await performQuery(
`
CREATE FOREIGN DATA WRAPPER "${wrapper.toLowerCase()}_fdw"
HANDLER "${camelToSnakeCase(wrapper)}_fdw_handler"
VALIDATOR "${camelToSnakeCase(wrapper)}_fdw_validator";
`,
`create ${wrapper} "wrappers"`,
true,
true,
);
}
})
.catch((err) => {
// oxlint-disable-next-line no-console
console.error('Error during Data Source initialization:', err);
});
async function checkForeignDataWrapperExists(
wrapperName: string,
): Promise<boolean> {
const result = await rawDataSource.query(
`SELECT 1 FROM pg_foreign_data_wrapper WHERE fdwname = $1`,
[wrapperName],
);
return result.length > 0;
}
@@ -0,0 +1,44 @@
import { rawDataSource } from 'src/database/typeorm/raw/raw.datasource';
import { performQuery } from './setup-db-utils';
async function dropSchemasSequentially() {
try {
await rawDataSource.initialize();
// Fetch all schemas excluding the ones we want to keep
const schemas =
(await performQuery<{ schema_name: string }[]>(
`
SELECT n.nspname AS "schema_name"
FROM pg_catalog.pg_namespace n
WHERE n.nspname !~ '^pg_'
AND n.nspname <> 'information_schema'
AND n.nspname NOT IN ('metric_helpers', 'user_management', 'public')
`,
'Fetching schemas...',
)) ?? [];
const batchSize = 10;
for (let i = 0; i < schemas.length; i += batchSize) {
const batch = schemas.slice(i, i + batchSize);
await Promise.all(
batch.map((schema) =>
performQuery(
`DROP SCHEMA IF EXISTS "${schema.schema_name}" CASCADE;`,
`Dropping schema ${schema.schema_name}...`,
),
),
);
}
// oxlint-disable-next-line no-console
console.log('All schemas dropped successfully.');
} catch (err) {
// oxlint-disable-next-line no-console
console.error('Error during schema dropping:', err);
}
}
dropSchemasSequentially();