Twenty standard and workspace custom applications 1/3 (#15625)

# Introduction
related to https://github.com/twentyhq/core-team-issues/issues/1833
In this PR we're starting the sync-metadata and standardIds deprecation
by introducing `twenty-standard` application that will regroup every
standard object such as company and opportunities. But also the
`custom-workspace-application` which is an app created at the same time
as a workspace and that will regroup everything configure within the
workspace ( custom objects fields etc )

## What's done
On both new workspace and seeded workspace creation:
- Creating a custom workspace app
- Creating a twenty standard app
- Refactored the seed core schema and workspace creation to be run
within a transaction in order to handle circular dependency foreignkey
requirements ( which is deferred for app toward workspace )
- Updated workspace entity to have a custom workspace relation (
nullable for the moment until we implem an upgrade command to handle
retro comp )
- Integration testing on user, workspace creation deletion and expected
default apps creation
- ~~Soft deleted user on `deleteUser`~~ Done by marie and rebased on it

## What's next
- Update seeder to propagate the `twenty-standard` workspace
`applicationId` to every standard synchronized entities ( cheap and fast
iteration through the about to be deprecated sync-metadata as an easy
way to synchronize standards metadata entities ).
- Update seeder to propagate the `custom-workspace-application`
workspace `applicationId` to anything custom ( `pets` and `rockets` )
- Prepend `custom-workspace-application` `applicationId` to every
metadata API operations ( create a specific cache etc )
- Upgrade command on all existing workspace to create a custom app and
associate its applicationId to any existing custom entities
- Make `universalIdentifier` and `applicationId` required for any
syncable entity
This commit is contained in:
Paul Rastoin
2025-11-10 16:13:12 +01:00
committed by GitHub
parent 9a80164cf3
commit 7a1e699fc8
106 changed files with 2166 additions and 984 deletions
@@ -19,27 +19,27 @@ import {
ServerlessFunctionTriggerManifest,
} from 'src/engine/core-modules/application/types/application.types';
import { ApplicationVariableEntityService } from 'src/engine/core-modules/applicationVariable/application-variable.service';
import { Sources } from 'src/engine/core-modules/file-storage/types/source.type';
import { CronTriggerV2Service } from 'src/engine/metadata-modules/cron-trigger/services/cron-trigger-v2.service';
import { FlatCronTrigger } from 'src/engine/metadata-modules/cron-trigger/types/flat-cron-trigger.type';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { DatabaseEventTriggerV2Service } from 'src/engine/metadata-modules/database-event-trigger/services/database-event-trigger-v2.service';
import { FlatDatabaseEventTrigger } from 'src/engine/metadata-modules/database-event-trigger/types/flat-database-event-trigger.type';
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { FieldMetadataServiceV2 } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { getFlatEntitiesByApplicationId } from 'src/engine/metadata-modules/flat-entity/utils/get-flat-entities-by-application-id.util';
import { getSubFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-or-throw.util';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { RouteTriggerV2Service } from 'src/engine/metadata-modules/route-trigger/services/route-trigger-v2.service';
import { FlatRouteTrigger } from 'src/engine/metadata-modules/route-trigger/types/flat-route-trigger.type';
import { ServerlessFunctionLayerService } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.service';
import { ServerlessFunctionV2Service } from 'src/engine/metadata-modules/serverless-function/services/serverless-function-v2.service';
import { FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-validate-build-and-run-service';
import { Sources } from 'src/engine/core-modules/file-storage/types/source.type';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { FieldMetadataServiceV2 } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2';
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-validate-build-and-run-service';
@Injectable()
export class ApplicationSyncService {
@@ -81,13 +81,22 @@ export class ApplicationSyncService {
applicationId: application.id,
});
await this.syncServerlessFunctions({
serverlessFunctionsToSync: manifest.serverlessFunctions,
code: manifest.sources,
workspaceId,
applicationId: application.id,
serverlessFunctionLayerId: application.serverlessFunctionLayerId,
});
if (manifest.serverlessFunctions.length > 0) {
if (!isDefined(application.serverlessFunctionLayerId)) {
throw new ApplicationException(
`Failed to sync serverless function, could not find a serverless function layer.`,
ApplicationExceptionCode.FIELD_NOT_FOUND,
);
}
await this.syncServerlessFunctions({
serverlessFunctionsToSync: manifest.serverlessFunctions,
code: manifest.sources,
workspaceId,
applicationId: application.id,
serverlessFunctionLayerId: application.serverlessFunctionLayerId,
});
}
this.logger.log('✅ Application sync from manifest completed');
}
@@ -100,57 +109,46 @@ export class ApplicationSyncService {
}: ApplicationInput & {
workspaceId: string;
}): Promise<ApplicationEntity> {
const application = await this.applicationService.findByUniversalIdentifier(
manifest.application.universalIdentifier,
workspaceId,
);
const name = manifest.application.displayName ?? packageJson.name;
if (!isDefined(application)) {
const serverlessFunctionLayer =
await this.serverlessFunctionLayerService.create(
{
packageJson,
yarnLock,
},
workspaceId,
);
const application = await this.applicationService.create({
const application =
(await this.applicationService.findByUniversalIdentifier({
universalIdentifier: manifest.application.universalIdentifier,
workspaceId,
})) ??
(await this.applicationService.create({
universalIdentifier: manifest.application.universalIdentifier,
name,
description: manifest.application.description,
version: packageJson.version,
sourcePath: 'cli-sync', // Placeholder for CLI-synced apps
serverlessFunctionLayerId: serverlessFunctionLayer.id,
serverlessFunctionLayerId: null,
workspaceId,
});
}));
await this.applicationVariableService.upsertManyApplicationVariableEntities(
let serverlessFunctionLayerId = application.serverlessFunctionLayerId;
if (manifest.serverlessFunctions.length > 0) {
if (!isDefined(serverlessFunctionLayerId)) {
serverlessFunctionLayerId = (
await this.serverlessFunctionLayerService.create(
{
packageJson,
yarnLock,
},
workspaceId,
)
).id;
}
await this.serverlessFunctionLayerService.update(
serverlessFunctionLayerId,
{
applicationVariables: manifest.application.applicationVariables,
applicationId: application.id,
packageJson,
yarnLock,
},
);
return application;
}
await this.serverlessFunctionLayerService.update(
application.serverlessFunctionLayerId,
{
packageJson,
yarnLock,
},
);
await this.applicationService.update(application.id, {
name,
description: manifest.application.description,
version: packageJson.version,
});
await this.applicationVariableService.upsertManyApplicationVariableEntities(
{
applicationVariables: manifest.application.applicationVariables,
@@ -158,7 +156,12 @@ export class ApplicationSyncService {
},
);
return application;
return await this.applicationService.update(application.id, {
name,
description: manifest.application.description,
version: packageJson.version,
serverlessFunctionLayerId,
});
}
private async syncFields({
@@ -947,8 +950,7 @@ export class ApplicationSyncService {
);
const application = await this.applicationService.findByUniversalIdentifier(
applicationUniversalIdentifier,
workspaceId,
{ universalIdentifier: applicationUniversalIdentifier, workspaceId },
);
if (!isDefined(application)) {
@@ -35,8 +35,8 @@ export class ApplicationEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: true, type: 'uuid' })
universalIdentifier?: string;
@Column({ nullable: false, type: 'uuid' })
universalIdentifier: string;
@Column({ nullable: false, type: 'text' })
name: string;
@@ -56,8 +56,8 @@ export class ApplicationEntity {
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@Column({ nullable: false, type: 'uuid' })
serverlessFunctionLayerId: string;
@Column({ nullable: true, type: 'uuid' })
serverlessFunctionLayerId: string | null;
@ManyToOne(() => WorkspaceEntity, {
onDelete: 'CASCADE',
@@ -36,6 +36,7 @@ import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/
WorkspaceMigrationV2Module,
PermissionsModule,
],
exports: [ApplicationService],
providers: [ApplicationResolver, ApplicationService, ApplicationSyncService],
})
export class ApplicationModule {}
@@ -2,15 +2,15 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { type QueryRunner, Repository } from 'typeorm';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { PackageJson } from 'src/engine/core-modules/application/types/application.types';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/twenty-standard-applications';
@Injectable()
export class ApplicationService {
@@ -64,10 +64,13 @@ export class ApplicationService {
});
}
async findByUniversalIdentifier(
universalIdentifier: string,
workspaceId: string,
) {
async findByUniversalIdentifier({
universalIdentifier,
workspaceId,
}: {
universalIdentifier: string;
workspaceId: string;
}) {
return this.applicationRepository.findOne({
where: {
universalIdentifier,
@@ -76,34 +79,51 @@ export class ApplicationService {
});
}
async create(data: {
universalIdentifier?: string;
name: string;
description?: string;
version?: string;
serverlessFunctionLayerId: string;
sourcePath: string;
workspaceId: string;
}): Promise<ApplicationEntity> {
async createTwentyStandardApplication(
{
workspaceId,
}: {
workspaceId: string;
},
queryRunner?: QueryRunner,
) {
return await this.create(
{
...TWENTY_STANDARD_APPLICATION,
serverlessFunctionLayerId: null,
workspaceId,
},
queryRunner,
);
}
async create(
data: {
universalIdentifier?: string;
name: string;
description?: string;
version?: string;
serverlessFunctionLayerId: string | null;
sourcePath: string;
workspaceId: string;
},
queryRunner?: QueryRunner,
): Promise<ApplicationEntity> {
const application = this.applicationRepository.create({
...data,
sourceType: 'local',
});
if (queryRunner) {
return queryRunner.manager.save(ApplicationEntity, application);
}
return this.applicationRepository.save(application);
}
async update(
id: string,
data: {
name?: string;
description?: string;
version?: string;
sourcePath?: string;
packageJson?: PackageJson;
yarnLock?: string;
packageChecksum?: string;
},
data: Parameters<typeof this.applicationRepository.update>[1],
): Promise<ApplicationEntity> {
await this.applicationRepository.update({ id }, data);
@@ -117,10 +137,10 @@ export class ApplicationService {
}
async delete(universalIdentifier: string, workspaceId: string) {
const application = await this.findByUniversalIdentifier(
const application = await this.findByUniversalIdentifier({
universalIdentifier,
workspaceId,
);
});
if (!isDefined(application)) {
throw new Error(`Application does not exist`);
@@ -3,10 +3,10 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ServerlessFunctionDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto';
import { ApplicationVariableEntityDTO } from 'src/engine/core-modules/applicationVariable/dtos/application-variable.dto';
import { AgentDTO } from 'src/engine/metadata-modules/agent/dtos/agent.dto';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import { ApplicationVariableEntityDTO } from 'src/engine/core-modules/applicationVariable/dtos/application-variable.dto';
import { ServerlessFunctionDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto';
@ObjectType('Application')
export class ApplicationDTO {
@@ -38,4 +38,8 @@ export class ApplicationDTO {
@Field(() => [ApplicationVariableEntityDTO])
applicationVariables: ApplicationVariableEntityDTO[];
@IsString()
@Field()
universalIdentifier: string;
}