Metadata api create entity in workspace custom app (#15911)

# Introduction
Cleaner and fewer scope version of
https://github.com/twentyhq/twenty/pull/15745 ( removed sync-metadata
hack through, too ambitious migration and upgrade )

Please note that this PR won't have any interaction with the existing
sync-metadata
Which mean that the sync metadata does not update the standard entities
applicationId and universalIdentifier, and it won't we will deprecate it
on favor of a workspace migration aka twenty-standard app installation

## API Metadata
Any operation going through the api metadata nows automatically scope
the related entity to the workspace custom application instance. (
optionally passing an applicationId to allow current hacky implem of app
sync service )

We need to either ignore the tests or remove the cli status check from
the blocking status badges for a PR to be merged

## New workspace
Already handled in previous
https://github.com/twentyhq/twenty/pull/15625, when a workspace is
created it gets created a twenty standard and custom workspace instance

All his views and permissions will be prefilled to the its twenty
standard app instance with a specific universalIdentifier

## New universalIdentifier
At the contrary as before with standardIds, universalIdentifier are
unique for a given workspace
This means that createdAt field of both object company and opportunity
will have a unique universalIdentifier whereas they share the same
standardId

## FlatApplication
Introduced the flatApplication and cache. Will migrate existing
`MetadataName` to be `SyncableMetadataName` in a following PR

## What's next
Next we will describe a twenty standard app configuration as json that
will be used to generate a workspace migration that will be run instead
of the sync metadata, in a nutshell we aim to deprecated the sync
metadata
So we can standardize any entity to have a non nullable applicationId
and universalIdentifier

## Upgrade command
Introduced an upgrade command that will create a custom workspace
instance for any workspace that do not have one in order to align with
the new behavior when creating a new workspace
This commit is contained in:
Paul Rastoin
2025-11-23 22:35:17 +01:00
committed by GitHub
parent 4848bc03f3
commit f9ab09c404
99 changed files with 3458 additions and 730 deletions
@@ -0,0 +1,38 @@
import { Module } from '@nestjs/common';
import { ApplicationResolver } from 'src/engine/core-modules/application/application.resolver';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-sync.service';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/applicationVariable/application-variable.module';
import { CronTriggerModule } from 'src/engine/metadata-modules/cron-trigger/cron-trigger.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { DatabaseEventTriggerModule } from 'src/engine/metadata-modules/database-event-trigger/database-event-trigger.module';
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RouteTriggerModule } from 'src/engine/metadata-modules/route-trigger/route-trigger.module';
import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.module';
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module';
@Module({
imports: [
ApplicationModule,
ApplicationVariableEntityModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
ObjectMetadataModule,
FieldMetadataModule,
DataSourceModule,
ServerlessFunctionLayerModule,
ServerlessFunctionModule,
DatabaseEventTriggerModule,
CronTriggerModule,
RouteTriggerModule,
WorkspaceMigrationV2Module,
PermissionsModule,
],
providers: [ApplicationResolver, ApplicationSyncService],
exports: [ApplicationSyncService],
})
export class ApplicationSyncModule {}
@@ -284,6 +284,7 @@ export class ApplicationSyncService {
await this.fieldMetadataServiceV2.createOneField({
createFieldInput,
workspaceId,
applicationId,
});
}
}
@@ -406,6 +407,7 @@ export class ApplicationSyncService {
const createdObject = await this.objectMetadataServiceV2.createOneObject({
createObjectInput,
applicationId,
workspaceId,
});
@@ -526,18 +528,21 @@ export class ApplicationSyncService {
serverlessFunctionId: serverlessFunctionToUpdate.id,
triggersToSync: serverlessFunctionToSync.triggers || [],
workspaceId,
applicationId,
});
await this.syncCronTriggersForServerlessFunction({
serverlessFunctionId: serverlessFunctionToUpdate.id,
triggersToSync: serverlessFunctionToSync.triggers || [],
workspaceId,
applicationId,
});
await this.syncRouteTriggersForServerlessFunction({
serverlessFunctionId: serverlessFunctionToUpdate.id,
triggersToSync: serverlessFunctionToSync.triggers || [],
workspaceId,
applicationId,
});
}
@@ -567,18 +572,21 @@ export class ApplicationSyncService {
serverlessFunctionId: createdServerlessFunction.id,
triggersToSync: serverlessFunctionToCreate.triggers || [],
workspaceId,
applicationId,
});
await this.syncCronTriggersForServerlessFunction({
serverlessFunctionId: createdServerlessFunction.id,
triggersToSync: serverlessFunctionToCreate.triggers || [],
workspaceId,
applicationId,
});
await this.syncRouteTriggersForServerlessFunction({
serverlessFunctionId: createdServerlessFunction.id,
triggersToSync: serverlessFunctionToCreate.triggers || [],
workspaceId,
applicationId,
});
}
}
@@ -587,10 +595,12 @@ export class ApplicationSyncService {
serverlessFunctionId,
triggersToSync,
workspaceId,
applicationId,
}: {
serverlessFunctionId: string;
triggersToSync: ServerlessFunctionTriggerManifest[];
workspaceId: string;
applicationId: string;
}) {
const databaseEventTriggersToSync = triggersToSync.filter(
(trigger) => trigger.type === 'databaseEvent',
@@ -695,6 +705,7 @@ export class ApplicationSyncService {
await this.databaseEventTriggerV2Service.createOne(
createDatabaseEventTriggerInput,
workspaceId,
applicationId,
);
}
}
@@ -703,10 +714,12 @@ export class ApplicationSyncService {
serverlessFunctionId,
triggersToSync,
workspaceId,
applicationId,
}: {
serverlessFunctionId: string;
triggersToSync: ServerlessFunctionTriggerManifest[];
workspaceId: string;
applicationId: string;
}) {
const cronTriggersToSync = triggersToSync.filter(
(trigger) => trigger.type === 'cron',
@@ -808,6 +821,7 @@ export class ApplicationSyncService {
await this.cronTriggerV2Service.createOne(
createCronTriggerInput,
workspaceId,
applicationId,
);
}
}
@@ -816,10 +830,12 @@ export class ApplicationSyncService {
serverlessFunctionId,
triggersToSync,
workspaceId,
applicationId,
}: {
serverlessFunctionId: string;
triggersToSync: ServerlessFunctionTriggerManifest[];
workspaceId: string;
applicationId: string;
}) {
const routeTriggersToSync = triggersToSync.filter(
(trigger) => trigger.type === 'route',
@@ -922,6 +938,7 @@ export class ApplicationSyncService {
await this.routeTriggerV2Service.createOne(
createRouteTriggerInput,
workspaceId,
applicationId,
);
}
}
@@ -1,42 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-sync.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationResolver } from 'src/engine/core-modules/application/application.resolver';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/applicationVariable/application-variable.module';
import { WorkspaceFlatApplicationMapCacheService } from 'src/engine/core-modules/application/services/workspace-flat-application-map-cache.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { CronTriggerModule } from 'src/engine/metadata-modules/cron-trigger/cron-trigger.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { DatabaseEventTriggerModule } from 'src/engine/metadata-modules/database-event-trigger/database-event-trigger.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RouteTriggerModule } from 'src/engine/metadata-modules/route-trigger/route-trigger.module';
import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.module';
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module';
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
@Module({
imports: [
TypeOrmModule.forFeature([ApplicationEntity, AgentEntity, WorkspaceEntity]),
WorkspaceManyOrAllFlatEntityMapsCacheModule,
ObjectMetadataModule,
FieldMetadataModule,
DataSourceModule,
ApplicationVariableEntityModule,
ServerlessFunctionLayerModule,
ServerlessFunctionModule,
DatabaseEventTriggerModule,
CronTriggerModule,
RouteTriggerModule,
WorkspaceMigrationV2Module,
PermissionsModule,
],
exports: [ApplicationService],
providers: [ApplicationResolver, ApplicationService, ApplicationSyncService],
exports: [ApplicationService, WorkspaceFlatApplicationMapCacheService],
providers: [ApplicationService, WorkspaceFlatApplicationMapCacheService],
})
export class ApplicationModule {}
@@ -3,23 +3,86 @@ import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { type QueryRunner, Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/core-modules/application/constants/twenty-standard-applications';
import { WorkspaceFlatApplicationMapCacheService } from 'src/engine/core-modules/application/services/workspace-flat-application-map-cache.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
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 {
constructor(
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly workspaceFlatApplicationMapCacheService: WorkspaceFlatApplicationMapCacheService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
) {}
async findWorkspaceTwentyStandardAndCustomApplicationOrThrow({
workspaceId,
}: {
workspaceId: string;
}) {
const workspace = await this.workspaceRepository.findOne({
where: {
id: workspaceId,
},
});
if (!isDefined(workspace)) {
throw new ApplicationException(
`Could not find workspace ${workspaceId}`,
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
);
}
const flatApplicationMaps =
await this.workspaceFlatApplicationMapCacheService.getExistingOrRecomputeFlatMaps(
{
workspaceId,
},
);
const twentyStandardApplicationId =
flatApplicationMaps.idByUniversalIdentifier[
TWENTY_STANDARD_APPLICATION.universalIdentifier
];
if (!isDefined(twentyStandardApplicationId)) {
throw new ApplicationException(
`Could not find workspace twenty standard applicationId in cache ${workspaceId}`,
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
);
}
const twentyStandardFlatApplication =
flatApplicationMaps.byId[twentyStandardApplicationId];
const workspaceCustomFlatApplication =
flatApplicationMaps.byId[workspace.workspaceCustomApplicationId];
if (
!isDefined(twentyStandardFlatApplication) ||
!isDefined(workspaceCustomFlatApplication)
) {
throw new ApplicationException(
`Could not find workspace custom and standard applications ${workspace.id}`,
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
);
}
return {
twentyStandardFlatApplication,
workspaceCustomFlatApplication,
};
}
async findManyApplications(
workspaceId: string,
): Promise<ApplicationEntity[]> {
@@ -82,31 +145,63 @@ export class ApplicationService {
async createTwentyStandardApplication(
{
workspaceId,
skipCacheInvalidation = false,
}: {
workspaceId: string;
skipCacheInvalidation?: boolean;
},
queryRunner?: QueryRunner,
) {
return await this.create(
const twentyStandardApplication = await this.create(
{
...TWENTY_STANDARD_APPLICATION,
serverlessFunctionLayerId: null,
workspaceId,
canBeUninstalled: false,
},
queryRunner,
);
if (!skipCacheInvalidation) {
await this.workspaceFlatApplicationMapCacheService.invalidateCache({
workspaceId,
});
}
return twentyStandardApplication;
}
async createWorkspaceCustomApplication(
{
workspaceId,
workspaceDisplayName,
}: {
workspaceId: string;
workspaceDisplayName?: string;
},
queryRunner?: QueryRunner,
) {
const applicationId = v4();
const workspaceCustomApplication = await this.create(
{
description: 'Workspace custom application',
name: `${isDefined(workspaceDisplayName) ? workspaceDisplayName : 'Workspace'}'s custom application`,
sourcePath: 'workspace-custom',
version: '1.0.0',
universalIdentifier: applicationId,
workspaceId: workspaceId,
id: applicationId,
serverlessFunctionLayerId: null,
canBeUninstalled: false,
},
queryRunner,
);
return workspaceCustomApplication;
}
async create(
data: {
universalIdentifier?: string;
name: string;
description?: string;
version?: string;
serverlessFunctionLayerId: string | null;
sourcePath: string;
workspaceId: string;
},
data: Partial<ApplicationEntity> & { workspaceId: string },
queryRunner?: QueryRunner,
): Promise<ApplicationEntity> {
const application = this.applicationRepository.create({
@@ -118,7 +213,13 @@ export class ApplicationService {
return queryRunner.manager.save(ApplicationEntity, application);
}
return this.applicationRepository.save(application);
const savedApplication = await this.applicationRepository.save(application);
await this.workspaceFlatApplicationMapCacheService.invalidateCache({
workspaceId: data.workspaceId,
});
return savedApplication;
}
async update(
@@ -127,6 +228,10 @@ export class ApplicationService {
): Promise<ApplicationEntity> {
await this.applicationRepository.update({ id }, data);
await this.workspaceFlatApplicationMapCacheService.invalidateCache({
workspaceId: data.workspaceId as string,
});
const updatedApplication = await this.findById(id);
if (!updatedApplication) {
@@ -151,8 +256,14 @@ export class ApplicationService {
workspaceId,
});
await this.flatEntityMapsCacheService.invalidateFlatEntityMaps({
await this.workspaceFlatApplicationMapCacheService.invalidateCache({
workspaceId,
});
await this.workspaceManyOrAllFlatEntityMapsCacheService.invalidateFlatEntityMaps(
{
workspaceId,
},
);
}
}
@@ -0,0 +1,9 @@
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
export const APPLICATION_ENTITY_RELATION_PROPERTIES = [
'workspace',
'agents',
'serverlessFunctions',
'objects',
'applicationVariables',
] as const satisfies (keyof ApplicationEntity)[];
@@ -0,0 +1,19 @@
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
export const TWENTY_STANDARD_APPLICATION = {
universalIdentifier: '20202020-64aa-4b6f-b003-9c74b97cee20',
name: 'Twenty Standard',
description:
'Twenty is an open-source CRM that allows you to manage your sales and customer relationships',
version: '1.0.0',
sourcePath: 'cli-sync',
sourceType: 'local',
} as const satisfies Pick<
ApplicationEntity,
| 'universalIdentifier'
| 'name'
| 'description'
| 'version'
| 'sourcePath'
| 'sourceType'
>;
@@ -0,0 +1,60 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { AllMetadataName } from 'twenty-shared/metadata';
import { Repository } from 'typeorm';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { FlatApplicationCacheMaps } from 'src/engine/core-modules/application/types/flat-application-cache-maps.type';
import { fromApplicationEntityToFlatApplication } from 'src/engine/core-modules/application/utils/from-application-entity-to-flat-application.util';
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { MetadataToFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/types/metadata-to-flat-entity-maps-key';
import { WorkspaceFlatMapCache } from 'src/engine/workspace-flat-map-cache/decorators/workspace-flat-map-cache.decorator';
import { WorkspaceFlatMapCacheService } from 'src/engine/workspace-flat-map-cache/services/workspace-flat-map-cache.service';
@Injectable()
@WorkspaceFlatMapCache(
'flatApplicationMaps' as MetadataToFlatEntityMapsKey<AllMetadataName>,
) // TODO prastoin introduce SyncableMetadata notion
export class WorkspaceFlatApplicationMapCacheService extends WorkspaceFlatMapCacheService<FlatApplicationCacheMaps> {
constructor(
@InjectCacheStorage(CacheStorageNamespace.EngineWorkspace)
cacheStorageService: CacheStorageService,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
) {
super(cacheStorageService);
}
protected async computeFlatMap({
workspaceId,
}: {
workspaceId: string;
}): Promise<FlatApplicationCacheMaps> {
const applicationEntities = await this.applicationRepository.find({
where: {
workspaceId,
},
withDeleted: true,
});
const flatApplicationMaps: FlatApplicationCacheMaps = {
byId: {},
idByUniversalIdentifier: {},
};
for (const applicationEntity of applicationEntities) {
const flatApplication =
fromApplicationEntityToFlatApplication(applicationEntity);
flatApplicationMaps.byId[flatApplication.id] = flatApplication;
flatApplicationMaps.idByUniversalIdentifier[
flatApplication.universalIdentifier
] = flatApplication.id;
}
return flatApplicationMaps;
}
}
@@ -0,0 +1,4 @@
import { type APPLICATION_ENTITY_RELATION_PROPERTIES } from 'src/engine/core-modules/application/constants/application-entity-relation-properties.constant';
export type ApplicationEntityRelationProperties =
(typeof APPLICATION_ENTITY_RELATION_PROPERTIES)[number];
@@ -0,0 +1,6 @@
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
export type FlatApplicationCacheMaps = {
byId: Partial<Record<string, FlatApplication>>;
idByUniversalIdentifier: Partial<Record<string, string>>;
};
@@ -0,0 +1,7 @@
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { type ApplicationEntityRelationProperties } from 'src/engine/core-modules/application/types/application-entity-relation-properties.type';
export type FlatApplication = Omit<
ApplicationEntity,
ApplicationEntityRelationProperties
>;
@@ -0,0 +1,13 @@
import { removePropertiesFromRecord } from 'twenty-shared/utils';
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { APPLICATION_ENTITY_RELATION_PROPERTIES } from 'src/engine/core-modules/application/constants/application-entity-relation-properties.constant';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
export const fromApplicationEntityToFlatApplication = (
applicationEntity: ApplicationEntity,
): FlatApplication =>
removePropertiesFromRecord(
applicationEntity,
APPLICATION_ENTITY_RELATION_PROPERTIES,
);