Refactor application module architecture for clarity and explicitness (#18432)

## Summary

- **Module reorganization**: Moved `ApplicationUpgradeService` and cron
jobs to `application-upgrade/`, `ApplicationSyncService` to
`application-manifest/`, and
`runWorkspaceMigration`/`uninstallApplication` mutations to the manifest
resolver — each module now has a single clear responsibility.
- **Explicit install flow**: Removed implicit `ApplicationEntity`
creation from `ApplicationSyncService`. The install service and dev
resolver now explicitly create the `ApplicationEntity` before syncing.
npm packages are resolved at registration time to extract manifest
metadata (universalIdentifier, name, description, etc.), eliminating the
`reconcileUniversalIdentifier` hack.
- **Better error handling**: Frontend hooks now surface actual server
error messages in snackbars instead of swallowing them. Replaced the
ugly `ConfirmationModal` for transfer ownership with a proper form
modal. Fixed `SettingsAdminTableCard` row height overflow and corrected
the `yarn-engine` asset path.

## Test plan
- [ ] Register an npm package — verify manifest metadata (name,
description, universalIdentifier) is extracted correctly
- [ ] Install a registered npm app on a workspace — verify
ApplicationEntity is created and sync succeeds
- [ ] Test `app:dev` CLI flow — verify local app registration and sync
work
- [ ] Upload a tarball — verify registration and install flow
- [ ] Transfer ownership — verify the new modal UX works
- [ ] Verify error messages appear correctly in snackbars when
operations fail


Made with [Cursor](https://cursor.com)
This commit is contained in:
Félix Malfait
2026-03-06 08:45:08 +01:00
committed by GitHub
parent 90cced0e74
commit 514d0017ea
184 changed files with 3179 additions and 1382 deletions
@@ -9,7 +9,7 @@ import { type CommonResponseBody } from 'test/integration/metadata/types/common-
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 ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration/application-registration-variable.entity';
import { type ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
type VariableFields = Pick<
ApplicationRegistrationVariableEntity,
@@ -4,10 +4,8 @@ exports[`Install application should fail when feature flag is disabled should fa
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "FORBIDDEN",
"userFriendlyMessage": "You do not have permission to perform this action.",
"userFriendlyMessage": "An error occurred.",
},
"message": "Application installation from tarball is not enabled",
"name": "ForbiddenError",
"message": "Feature flag "IS_APPLICATION_ENABLED" is not enabled for this workspace",
}
`;
@@ -1,5 +1,17 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Install application should fail when entity does not exist should fail with execution error when installing non-existent app registration 1`] = `
{
"extensions": {
"code": "NOT_FOUND",
"subCode": "APPLICATION_NOT_FOUND",
"userFriendlyMessage": "Application not found.",
},
"message": "Application registration with id 20202020-0000-0000-0000-000000000000 not found",
"name": "NotFoundError",
}
`;
exports[`Install application should fail when entity does not exist should fail when a role has an invalid universalIdentifier 1`] = `
{
"extensions": {
@@ -34,27 +46,3 @@ exports[`Install application should fail when entity does not exist should fail
"name": "GraphQLError",
}
`;
exports[`Install application should fail when entity does not exist should fail with execution error when deleting non-existent field metadata 1`] = `
{
"eventId": Any<String>,
"extensions": {
"action": {
"metadataName": "fieldMetadata",
"type": "delete",
"universalIdentifier": Any<String>,
},
"code": "APPLICATION_INSTALLATION_FAILED",
"errors": {
"actionTranspilation": {
"code": "ENTITY_NOT_FOUND",
"message": "Could not find flat entity with universal identifier 20202020-6110-4547-9fd0-2525257a2c3f",
},
},
"exceptionEventId": Any<String>,
"userFriendlyMessage": "Migration execution failed.",
},
"message": "Migration action 'delete' for 'fieldMetadata' failed",
"name": "GraphQLError",
}
`;
@@ -1,20 +1,30 @@
import { FeatureFlagKey } from 'twenty-shared/types';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { installApplication } from 'test/integration/metadata/suites/application/utils/install-application.util';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
describe('Install application should fail when feature flag is disabled', () => {
beforeAll(async () => {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_APPLICATION_ENABLED,
value: false,
expectToFail: false,
});
});
afterAll(async () => {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_APPLICATION_ENABLED,
value: true,
expectToFail: false,
});
});
it('should fail with forbidden error when feature flag is disabled', async () => {
const { errors } = await installApplication({
expectToFail: true,
input: {
workspaceMigration: {
actions: [
{
type: 'delete',
metadataName: 'fieldMetadata',
universalIdentifier: '20202020-784f-4042-b58f-ae8dbf718f6e',
},
],
},
appRegistrationId: '20202020-0000-0000-0000-000000000000',
},
});
@@ -12,8 +12,6 @@ const INVALID_UUID_APP_ID = uuidv4();
const INVALID_UUID_ROLE_ID = uuidv4();
describe('Install application should fail when entity does not exist', () => {
let appCreated = false;
beforeAll(async () => {
await updateFeatureFlag({
featureFlag:
@@ -21,15 +19,15 @@ describe('Install application should fail when entity does not exist', () => {
value: true,
expectToFail: false,
});
});
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: INVALID_UUID_APP_ID,
name: 'Test Invalid UUID App',
description: 'App for testing UUID v4 validation',
sourcePath: 'test-invalid-uuid',
});
appCreated = true;
}, 60000);
afterAll(async () => {
@@ -42,29 +40,40 @@ describe('Install application should fail when entity does not exist', () => {
});
afterEach(async () => {
if (!appCreated) {
return;
try {
await uninstallApplication({
universalIdentifier: INVALID_UUID_APP_ID,
expectToFail: false,
});
} catch {
// May fail if the test didn't install/sync
}
await uninstallApplication({
universalIdentifier: INVALID_UUID_APP_ID,
expectToFail: false,
});
await globalThis.testDataSource.query(
`DELETE FROM core."file" WHERE "applicationId" IN (
SELECT id FROM core."application" WHERE "universalIdentifier" = $1
)`,
[INVALID_UUID_APP_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."application"
WHERE "universalIdentifier" = $1`,
[INVALID_UUID_APP_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."applicationRegistration"
WHERE "universalIdentifier" = $1`,
[INVALID_UUID_APP_ID],
);
});
it('should fail with execution error when deleting non-existent field metadata', async () => {
it('should fail with execution error when installing non-existent app registration', async () => {
const { errors } = await installApplication({
expectToFail: true,
input: {
workspaceMigration: {
actions: [
{
type: 'delete',
metadataName: 'fieldMetadata',
universalIdentifier: '20202020-6110-4547-9fd0-2525257a2c3f',
},
],
},
appRegistrationId: '20202020-0000-0000-0000-000000000000',
},
});
@@ -3,7 +3,6 @@ import { buildBaseManifest } from 'test/integration/metadata/suites/application/
import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { uninstallApplication } from 'test/integration/metadata/suites/application/utils/uninstall-application.util';
import { type Manifest, type ObjectManifest } from 'twenty-shared/application';
import {
type EachTestingContext,
@@ -157,8 +156,6 @@ const failingSyncApplicationSystemFieldsTestCases: SyncApplicationTestingContext
];
describe('Sync application should fail due to object system fields integrity', () => {
let appCreated = false;
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
@@ -166,19 +163,32 @@ describe('Sync application should fail due to object system fields integrity', (
description: 'App for testing system field validation',
sourcePath: 'test-system-fields',
});
appCreated = true;
}, 60000);
afterEach(async () => {
if (!appCreated) {
return;
}
afterAll(async () => {
await globalThis.testDataSource.query(
`DELETE FROM core."role" WHERE "universalIdentifier" = $1`,
[TEST_ROLE_ID],
);
await uninstallApplication({
universalIdentifier: TEST_APP_ID,
expectToFail: false,
});
await globalThis.testDataSource.query(
`DELETE FROM core."file" WHERE "applicationId" IN (
SELECT id FROM core."application" WHERE "universalIdentifier" = $1
)`,
[TEST_APP_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."application"
WHERE "universalIdentifier" = $1`,
[TEST_APP_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."applicationRegistration"
WHERE "universalIdentifier" = $1`,
[TEST_APP_ID],
);
});
it.each(
@@ -68,8 +68,8 @@ describe('Marketplace Catalog Sync (integration)', () => {
(id, "universalIdentifier", name, "oAuthClientId",
"oAuthRedirectUris", "oAuthScopes", "workspaceId",
"sourceType", "sourcePackage", "latestAvailableVersion",
"marketplaceDisplayData")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
"marketplaceDisplayData", "isListed")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
[
id,
params.universalIdentifier,
@@ -84,6 +84,7 @@ describe('Marketplace Catalog Sync (integration)', () => {
params.marketplaceDisplayData
? JSON.stringify(params.marketplaceDisplayData)
: null,
true,
],
);
@@ -52,10 +52,38 @@ describe('Manifest update - fields', () => {
}, 60000);
afterEach(async () => {
await uninstallApplication({
universalIdentifier: TEST_APP_ID,
expectToFail: false,
});
try {
await uninstallApplication({
universalIdentifier: TEST_APP_ID,
expectToFail: false,
});
} catch {
// May fail if the test didn't fully install/sync
}
await globalThis.testDataSource.query(
`DELETE FROM core."role" WHERE "universalIdentifier" = $1`,
[TEST_ROLE_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."file" WHERE "applicationId" IN (
SELECT id FROM core."application" WHERE "universalIdentifier" = $1
)`,
[TEST_APP_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."application"
WHERE "universalIdentifier" = $1`,
[TEST_APP_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."applicationRegistration"
WHERE "universalIdentifier" = $1`,
[TEST_APP_ID],
);
});
it('should create a new field when added to manifest on second sync', async () => {
@@ -41,10 +41,38 @@ describe('Manifest update - objects', () => {
}, 60000);
afterEach(async () => {
await uninstallApplication({
universalIdentifier: TEST_APP_ID,
expectToFail: false,
});
try {
await uninstallApplication({
universalIdentifier: TEST_APP_ID,
expectToFail: false,
});
} catch {
// May fail if the test didn't fully install/sync
}
await globalThis.testDataSource.query(
`DELETE FROM core."role" WHERE "universalIdentifier" = $1`,
[TEST_ROLE_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."file" WHERE "applicationId" IN (
SELECT id FROM core."application" WHERE "universalIdentifier" = $1
)`,
[TEST_APP_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."application"
WHERE "universalIdentifier" = $1`,
[TEST_APP_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."applicationRegistration"
WHERE "universalIdentifier" = $1`,
[TEST_APP_ID],
);
});
it('should create a new object when added to manifest on second sync', async () => {
@@ -7,6 +7,7 @@ import { type Manifest } from 'twenty-shared/application';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_WORKSPACE_ID = '20202020-1c25-4d02-bf25-6aeccf7ea419';
const TEST_ROLE_ID = uuidv4();
const TEST_SECOND_ROLE_ID = uuidv4();
@@ -50,10 +51,38 @@ describe('Manifest update - roles', () => {
}, 60000);
afterEach(async () => {
await uninstallApplication({
universalIdentifier: TEST_APP_ID,
expectToFail: false,
});
try {
await uninstallApplication({
universalIdentifier: TEST_APP_ID,
expectToFail: false,
});
} catch {
// Application may not have been installed if the test failed early
}
await globalThis.testDataSource.query(
`DELETE FROM core."role" WHERE "universalIdentifier" IN ($1, $2)`,
[TEST_ROLE_ID, TEST_SECOND_ROLE_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."file" WHERE "applicationId" IN (
SELECT id FROM core."application" WHERE "universalIdentifier" = $1
)`,
[TEST_APP_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."application"
WHERE "universalIdentifier" = $1 AND "workspaceId" = $2`,
[TEST_APP_ID, TEST_WORKSPACE_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."applicationRegistration"
WHERE "universalIdentifier" = $1 AND "workspaceId" = $2`,
[TEST_APP_ID, TEST_WORKSPACE_ID],
);
});
it('should create a new role when added to manifest on second sync', async () => {
@@ -39,10 +39,38 @@ describe('Manifest update - skills', () => {
}, 60000);
afterEach(async () => {
await uninstallApplication({
universalIdentifier: TEST_APP_ID,
expectToFail: false,
});
try {
await uninstallApplication({
universalIdentifier: TEST_APP_ID,
expectToFail: false,
});
} catch {
// May fail if the test didn't fully install/sync
}
await globalThis.testDataSource.query(
`DELETE FROM core."role" WHERE "universalIdentifier" = $1`,
[TEST_ROLE_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."file" WHERE "applicationId" IN (
SELECT id FROM core."application" WHERE "universalIdentifier" = $1
)`,
[TEST_APP_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."application"
WHERE "universalIdentifier" = $1`,
[TEST_APP_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."applicationRegistration"
WHERE "universalIdentifier" = $1`,
[TEST_APP_ID],
);
});
it('should create a new skill when added to manifest on second sync', async () => {
@@ -61,28 +61,48 @@ const buildManifest = (
});
describe('syncApplication', () => {
let appCreated = false;
beforeAll(async () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'A test application',
sourcePath: 'test-sync',
});
appCreated = true;
}, 60000);
afterEach(async () => {
if (!appCreated) {
return;
try {
await uninstallApplication({
universalIdentifier: TEST_APP_ID,
expectToFail: false,
});
} catch {
// May fail if the test didn't fully install/sync
}
await uninstallApplication({
universalIdentifier: TEST_APP_ID,
expectToFail: false,
});
await globalThis.testDataSource.query(
`DELETE FROM core."role" WHERE "universalIdentifier" = $1`,
[TEST_ROLE_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."file" WHERE "applicationId" IN (
SELECT id FROM core."application" WHERE "universalIdentifier" = $1
)`,
[TEST_APP_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."application"
WHERE "universalIdentifier" = $1`,
[TEST_APP_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."applicationRegistration"
WHERE "universalIdentifier" = $1`,
[TEST_APP_ID],
);
});
it('should return workspace migration actions on initial sync then on second sync with field rename and new role', async () => {
@@ -4,7 +4,7 @@ import { type CommonResponseBody } from 'test/integration/metadata/types/common-
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 ApplicationTokenPairDTO } from 'src/engine/core-modules/application/dtos/application-token-pair.dto';
import { type ApplicationTokenPairDTO } from 'src/engine/core-modules/application/application-oauth/dtos/application-token-pair.dto';
export const generateApplicationToken = async ({
applicationId,
@@ -1,13 +1,8 @@
import gql from 'graphql-tag';
export type InstallApplicationFactoryInput = {
workspaceMigration: {
actions: {
type: 'delete';
metadataName: string;
universalIdentifier: string;
}[];
};
appRegistrationId: string;
version?: string;
};
export const installApplicationQueryFactory = ({
@@ -16,11 +11,15 @@ export const installApplicationQueryFactory = ({
input: InstallApplicationFactoryInput;
}) => ({
query: gql`
mutation InstallApplication($workspaceMigration: WorkspaceMigrationInput!) {
installApplication(workspaceMigration: $workspaceMigration)
mutation InstallApplication($appRegistrationId: String!, $version: String) {
installApplication(
appRegistrationId: $appRegistrationId
version: $version
)
}
`,
variables: {
workspaceMigration: input.workspaceMigration,
appRegistrationId: input.appRegistrationId,
version: input.version,
},
});
@@ -1,6 +1,9 @@
import { createOneApplication } from 'test/integration/metadata/suites/application/utils/create-one-application.util';
import crypto from 'crypto';
import { uploadApplicationFile } from 'test/integration/metadata/suites/application/utils/upload-application-file.util';
const TEST_WORKSPACE_ID = '20202020-1c25-4d02-bf25-6aeccf7ea419';
export const setupApplicationForSync = async ({
applicationUniversalIdentifier,
name,
@@ -12,16 +15,46 @@ export const setupApplicationForSync = async ({
description: string;
sourcePath: string;
}) => {
await createOneApplication({
universalIdentifier: applicationUniversalIdentifier,
name,
description,
version: '1.0.0',
sourcePath,
expectToFail: false,
});
const registrationId = crypto.randomUUID();
const applicationId = crypto.randomUUID();
const oAuthClientId = crypto.randomUUID();
await globalThis.testDataSource.query(
`INSERT INTO core."applicationRegistration"
(id, "universalIdentifier", name, description, "oAuthClientId",
"oAuthRedirectUris", "oAuthScopes", "workspaceId", "sourceType")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
registrationId,
applicationUniversalIdentifier,
name,
description,
oAuthClientId,
[],
[],
TEST_WORKSPACE_ID,
'local',
],
);
await globalThis.testDataSource.query(
`INSERT INTO core."application"
(id, "universalIdentifier", name, description, version, "sourcePath",
"sourceType", "workspaceId", "applicationRegistrationId")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
applicationId,
applicationUniversalIdentifier,
name,
description,
'1.0.0',
sourcePath,
'local',
TEST_WORKSPACE_ID,
registrationId,
],
);
// File upload uses multipart which requires real timers
jest.useRealTimers();
const packageJson = JSON.stringify({