[DO_NOT_RELEASE_MAIN_UNTIL_MERGED] Prevent migration failure due to workspace orphan metadata rows (#16863)

# Introduction
The `AddWorkspaceForeignKeys1767002571103` migration would fail when
released in production right now, as `foreignKey` be applicable as
there's a lof of orphan entries in database

As a workaround in order not to block any patch release we're
fallbacking the migration using save point and an upgrade command that
will attempt to apply the `foreignKey` on every workspace upgrade until
it succeed

We should keep in mind that any new fresh self installation will have
the foreignKey double checked that it would not implies regression on
workspace deletion using the integration tests

## Cleaning upgrade command
We won't implement the cleaning command in this PR yet either will I as
discussed with @Weiko someone else might be taking the subject starting
next week

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Strengthens workspace data integrity and makes the FK migration
resilient.
> 
> - Adds `upgrade:1-16:add-workspace-foreign-keys-migration` command to
apply `workspaceId` FKs once per run; wires into
`V1_16_UpgradeVersionCommandModule` and 1.16 upgrade sequence
> - Refactors migration `1767002571103` to use
`addWorkspaceForeignKeysQueries` util and wrap in a savepoint,
swallowing errors to avoid blocking releases
> - Extracts FK DDL into
`utils/1767002571103-addWorkspaceForeignKeys.util` for reuse by command
and migration
> - Removes duplicate `workspaceId` columns from entities (e.g.,
`cronTrigger`, `databaseEventTrigger`, `indexMetadata`,
`objectMetadata`, `roleTarget`, `role`, `serverlessFunction`) relying on
`SyncableEntity`; keeps indexes/relations
> - Marks legacy delete paths as deprecated; temporarily extends
`WorkspaceManagerService.delete` to also delete `serverlessFunction` by
`workspaceId`
> - Updates wiring to inject `ServerlessFunctionEntity` repository in
`workspace-manager` module/service and corresponding unit test
> - Extends integration tests and adds GraphQL helpers to create
serverless functions and triggers; verifies cascade deletion of related
metadata on workspace removal
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
6805bf5d1b32828b4bb1e9f130bfe6e478f66aee. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
This commit is contained in:
Paul Rastoin
2025-12-31 12:52:21 +01:00
committed by GitHub
parent f8fa709abf
commit eeb91d9a96
28 changed files with 617 additions and 99 deletions
@@ -0,0 +1,27 @@
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { type CreateCronTriggerInput } from 'src/engine/metadata-modules/cron-trigger/dtos/create-cron-trigger.input';
export type CreateOneCronTriggerFactoryInput = CreateCronTriggerInput;
const DEFAULT_CRON_TRIGGER_GQL_FIELDS = `
id
settings
`;
export const createOneCronTriggerQueryFactory = ({
input,
gqlFields = DEFAULT_CRON_TRIGGER_GQL_FIELDS,
}: PerformMetadataQueryParams<CreateOneCronTriggerFactoryInput>) => ({
query: gql`
mutation CreateOneCronTrigger($input: CreateCronTriggerInput!) {
createOneCronTrigger(input: $input) {
${gqlFields}
}
}
`,
variables: {
input,
},
});
@@ -0,0 +1,43 @@
import {
type CreateOneCronTriggerFactoryInput,
createOneCronTriggerQueryFactory,
} from 'test/integration/metadata/suites/serverless-function/utils/create-one-cron-trigger-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type CronTriggerDTO } from 'src/engine/metadata-modules/cron-trigger/dtos/cron-trigger.dto';
export const createOneCronTrigger = async ({
input,
gqlFields,
expectToFail = false,
token,
}: PerformMetadataQueryParams<CreateOneCronTriggerFactoryInput>): CommonResponseBody<{
createOneCronTrigger: CronTriggerDTO;
}> => {
const graphqlOperation = createOneCronTriggerQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Cron Trigger creation should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Cron Trigger creation has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};
@@ -0,0 +1,28 @@
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { type CreateDatabaseEventTriggerInput } from 'src/engine/metadata-modules/database-event-trigger/dtos/create-database-event-trigger.input';
export type CreateOneDatabaseEventTriggerFactoryInput =
CreateDatabaseEventTriggerInput;
const DEFAULT_DATABASE_EVENT_TRIGGER_GQL_FIELDS = `
id
settings
`;
export const createOneDatabaseEventTriggerQueryFactory = ({
input,
gqlFields = DEFAULT_DATABASE_EVENT_TRIGGER_GQL_FIELDS,
}: PerformMetadataQueryParams<CreateOneDatabaseEventTriggerFactoryInput>) => ({
query: gql`
mutation CreateOneDatabaseEventTrigger($input: CreateDatabaseEventTriggerInput!) {
createOneDatabaseEventTrigger(input: $input) {
${gqlFields}
}
}
`,
variables: {
input,
},
});
@@ -0,0 +1,44 @@
import {
type CreateOneDatabaseEventTriggerFactoryInput,
createOneDatabaseEventTriggerQueryFactory,
} from 'test/integration/metadata/suites/serverless-function/utils/create-one-database-event-trigger-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type DatabaseEventTriggerDTO } from 'src/engine/metadata-modules/database-event-trigger/dtos/database-event-trigger.dto';
export const createOneDatabaseEventTrigger = async ({
input,
gqlFields,
expectToFail = false,
token,
}: PerformMetadataQueryParams<CreateOneDatabaseEventTriggerFactoryInput>): CommonResponseBody<{
createOneDatabaseEventTrigger: DatabaseEventTriggerDTO;
}> => {
const graphqlOperation = createOneDatabaseEventTriggerQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage:
'Database Event Trigger creation should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Database Event Trigger creation has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};
@@ -0,0 +1,29 @@
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { type CreateRouteTriggerInput } from 'src/engine/metadata-modules/route-trigger/dtos/create-route-trigger.input';
export type CreateOneRouteTriggerFactoryInput = CreateRouteTriggerInput;
const DEFAULT_ROUTE_TRIGGER_GQL_FIELDS = `
id
path
isAuthRequired
httpMethod
`;
export const createOneRouteTriggerQueryFactory = ({
input,
gqlFields = DEFAULT_ROUTE_TRIGGER_GQL_FIELDS,
}: PerformMetadataQueryParams<CreateOneRouteTriggerFactoryInput>) => ({
query: gql`
mutation CreateOneRouteTrigger($input: CreateRouteTriggerInput!) {
createOneRouteTrigger(input: $input) {
${gqlFields}
}
}
`,
variables: {
input,
},
});
@@ -0,0 +1,43 @@
import {
type CreateOneRouteTriggerFactoryInput,
createOneRouteTriggerQueryFactory,
} from 'test/integration/metadata/suites/serverless-function/utils/create-one-route-trigger-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type RouteTriggerDTO } from 'src/engine/metadata-modules/route-trigger/dtos/route-trigger.dto';
export const createOneRouteTrigger = async ({
input,
gqlFields,
expectToFail = false,
token,
}: PerformMetadataQueryParams<CreateOneRouteTriggerFactoryInput>): CommonResponseBody<{
createOneRouteTrigger: RouteTriggerDTO;
}> => {
const graphqlOperation = createOneRouteTriggerQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Route Trigger creation should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Route Trigger creation has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};
@@ -0,0 +1,34 @@
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { type CreateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input';
export type CreateOneServerlessFunctionFactoryInput =
CreateServerlessFunctionInput;
const DEFAULT_SERVERLESS_FUNCTION_GQL_FIELDS = `
id
name
description
runtime
latestVersion
publishedVersions
createdAt
updatedAt
`;
export const createOneServerlessFunctionQueryFactory = ({
input,
gqlFields = DEFAULT_SERVERLESS_FUNCTION_GQL_FIELDS,
}: PerformMetadataQueryParams<CreateOneServerlessFunctionFactoryInput>) => ({
query: gql`
mutation CreateOneServerlessFunction($input: CreateServerlessFunctionInput!) {
createOneServerlessFunction(input: $input) {
${gqlFields}
}
}
`,
variables: {
input,
},
});
@@ -0,0 +1,44 @@
import {
type CreateOneServerlessFunctionFactoryInput,
createOneServerlessFunctionQueryFactory,
} from 'test/integration/metadata/suites/serverless-function/utils/create-one-serverless-function-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type ServerlessFunctionDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto';
export const createOneServerlessFunction = async ({
input,
gqlFields,
expectToFail = false,
token,
}: PerformMetadataQueryParams<CreateOneServerlessFunctionFactoryInput>): CommonResponseBody<{
createOneServerlessFunction: ServerlessFunctionDTO;
}> => {
const graphqlOperation = createOneServerlessFunctionQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage:
'Serverless Function creation should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Serverless Function creation has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};