[POC] Workspace migration builder v2 (#13026)

# Introduction
In this PR we've initialized the `workspace-migration-v2` folder.
Focusing on the builder in the first place.
From now it contains:
- Basic temporary types ( `fieldMetadataEntity` and
`ObjectMetadataEntity` )
- Object actions builder ( create, delete, update )
- Fields actions builder ( create, delete ) ( update coming in a
following PR )

We will still have to handle specific conditions such as:
- Index creation
- Uniqueness addition removal
- Relation

We also need to determine when we want to compute and transpile the
object no field `uniqueIdentifier`

We're aiming to merge this first in order to avoid accumulating code in
this PR

---------

Co-authored-by: prastoin <paul@twenty.com>
This commit is contained in:
Weiko
2025-07-07 09:59:54 +02:00
committed by GitHub
parent 0d2a196448
commit f6e38bd280
16 changed files with 1049 additions and 0 deletions

View File

@ -16,6 +16,7 @@ import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { DevSeederModule } from 'src/engine/workspace-manager/dev-seeder/dev-seeder.module'; import { DevSeederModule } from 'src/engine/workspace-manager/dev-seeder/dev-seeder.module';
import { WorkspaceHealthModule } from 'src/engine/workspace-manager/workspace-health/workspace-health.module'; import { WorkspaceHealthModule } from 'src/engine/workspace-manager/workspace-health/workspace-health.module';
import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module';
import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module'; import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module';
import { WorkspaceManagerService } from './workspace-manager.service'; import { WorkspaceManagerService } from './workspace-manager.service';
@ -24,6 +25,7 @@ import { WorkspaceManagerService } from './workspace-manager.service';
imports: [ imports: [
WorkspaceDataSourceModule, WorkspaceDataSourceModule,
WorkspaceMigrationModule, WorkspaceMigrationModule,
WorkspaceMigrationV2Module,
ObjectMetadataModule, ObjectMetadataModule,
DevSeederModule, DevSeederModule,
DataSourceModule, DataSourceModule,

View File

@ -0,0 +1,106 @@
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
type UniqueIdentifierRecord<TTarget extends string> = {
[P in `${TTarget}UniqueIdentifier`]: string;
};
type ObjectMetadataUniqueIdentifier = UniqueIdentifierRecord<'objectMetadata'>;
type FieldMetadataUniqueIdentifier = UniqueIdentifierRecord<'fieldMetadata'>;
export type FromTo<T> = {
from: T;
to: T;
};
type ObjectActionCommon = ObjectMetadataUniqueIdentifier;
export type CreateObjectAction = {
type: 'create_object';
object: ObjectMetadataEntity;
} & ObjectActionCommon;
export type UpdateObjectAction = {
type: 'update_object';
updates: (FromTo<Partial<ObjectMetadataEntity>> & { property: string })[];
} & ObjectActionCommon;
export type DeleteObjectAction = {
type: 'delete_object';
} & ObjectActionCommon;
export type WorkspaceMigrationV2ObjectAction =
| CreateObjectAction
| UpdateObjectAction
| DeleteObjectAction;
type FieldActionCommon = {
field: Partial<FieldMetadataEntity>;
} & ObjectMetadataUniqueIdentifier &
FieldMetadataUniqueIdentifier;
export type CreateFieldAction = {
type: 'create_field';
} & FieldActionCommon;
export type UpdateFieldAction = {
type: 'update_field';
} & FieldActionCommon;
export type DeleteFieldAction = {
type: 'delete_field';
} & Omit<FieldActionCommon, 'field'>;
export type WorkspaceMigrationFieldActionV2 =
| CreateFieldAction
| UpdateFieldAction
| DeleteFieldAction;
export interface CreateRelationAction {
type: 'create_relation';
}
export interface UpdateRelationAction {
type: 'update_relation';
}
export interface DeleteRelationAction {
type: 'delete_relation';
}
export type WorkspaceMigrationRelationActionV2 =
| CreateRelationAction
| UpdateRelationAction
| DeleteRelationAction;
export interface CreateIndexAction {
type: 'create_index';
}
export interface DeleteIndexAction {
type: 'delete_index';
}
export type WorkspaceMigrationIndexActionV2 =
| CreateIndexAction
| DeleteIndexAction;
export interface AddUniquenessConstraintAction {
type: 'add_uniqueness_constraint';
}
export interface RemoveUniquenessConstraintAction {
type: 'remove_uniqueness_constraint';
}
export type WorkspaceMigrationUniquenessActionV2 =
| RemoveUniquenessConstraintAction
| AddUniquenessConstraintAction;
export type WorkspaceMigrationActionV2 =
| WorkspaceMigrationRelationActionV2
| WorkspaceMigrationV2ObjectAction
| WorkspaceMigrationFieldActionV2
| WorkspaceMigrationUniquenessActionV2
| WorkspaceMigrationIndexActionV2;
export type WorkspaceMigrationActionTypeV2 = WorkspaceMigrationActionV2['type'];

View File

@ -0,0 +1,22 @@
import { FieldMetadataType } from 'twenty-shared/types';
export type WorkspaceMigrationObjectFieldInput = {
uniqueIdentifier: string;
name: string;
label: string;
defaultValue: unknown;
type: FieldMetadataType;
description?: string;
// TODO this should extend FieldMetadataEntity
};
export type WorkspaceMigrationObjectInput = {
uniqueIdentifier: string;
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
description?: string;
fields: WorkspaceMigrationObjectFieldInput[];
// TODO this should extend ObjectMetadataEntity
};

View File

@ -0,0 +1,12 @@
import { WorkspaceMigrationActionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/types/workspace-migration-action-v2';
export interface WorkspaceMigrationV2<
TActions extends WorkspaceMigrationActionV2 = WorkspaceMigrationActionV2,
> {
// formatVersion: 1;
// createdAt: string;
// name: string;
// description?: string;
actions: TActions[];
// objectActions: TActions[] // could be cool ?
}

View File

@ -0,0 +1,329 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { WorkspaceMigrationObjectInput } from 'src/engine/workspace-manager/workspace-migration-v2/types/workspace-migration-object-input';
import { WorkspaceMigrationBuilderV2Service } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-builder-v2.service';
describe('WorkspaceMigrationBuilderV2Service', () => {
let service: WorkspaceMigrationBuilderV2Service;
const baseObject: WorkspaceMigrationObjectInput = {
uniqueIdentifier: '20202020-e89b-12d3-a456-426614175000',
nameSingular: 'Contact',
namePlural: 'Contacts',
labelSingular: 'Contact',
labelPlural: 'Contacts',
description: 'A contact',
fields: [
{
uniqueIdentifier: '20202020-e89b-12d3-a456-426614174000',
name: 'firstName',
label: 'First Name',
type: FieldMetadataType.FULL_NAME,
defaultValue: '',
description: '',
},
],
};
beforeEach(() => {
service = new WorkspaceMigrationBuilderV2Service();
});
it('should return a migration when nameSingular changes', () => {
const from: WorkspaceMigrationObjectInput = baseObject;
const to: WorkspaceMigrationObjectInput = {
...from,
nameSingular: 'Person',
};
const result = service.build({ from: [from], to: [to] });
expect(result).toMatchInlineSnapshot(`
{
"actions": [
{
"objectMetadataUniqueIdentifier": "20202020-e89b-12d3-a456-426614175000",
"type": "update_object",
"updates": [
{
"from": "Contact",
"property": "nameSingular",
"to": "Person",
},
],
},
],
}
`);
});
it('should return a migration when creating a new object', () => {
const newObject: WorkspaceMigrationObjectInput = {
uniqueIdentifier: '20202020-e89b-12d3-a456-426614175001',
nameSingular: 'Company',
namePlural: 'Companies',
labelSingular: 'Company',
labelPlural: 'Companies',
description: 'A company',
fields: [
{
uniqueIdentifier: '20202020-e89b-12d3-a456-426614174001',
name: 'name',
label: 'Name',
type: FieldMetadataType.ADDRESS,
defaultValue: '',
description: '',
},
],
};
const result = service.build({ from: [], to: [newObject] });
expect(result).toMatchInlineSnapshot(`
{
"actions": [
{
"object": {
"description": "A company",
"fields": [
{
"defaultValue": "",
"description": "",
"label": "Name",
"name": "name",
"type": "ADDRESS",
"uniqueIdentifier": "20202020-e89b-12d3-a456-426614174001",
},
],
"labelPlural": "Companies",
"labelSingular": "Company",
"namePlural": "Companies",
"nameSingular": "Company",
"uniqueIdentifier": "20202020-e89b-12d3-a456-426614175001",
},
"objectMetadataUniqueIdentifier": "20202020-e89b-12d3-a456-426614175001",
"type": "create_object",
},
{
"field": {
"defaultValue": "",
"description": "",
"label": "Name",
"name": "name",
"type": "ADDRESS",
"uniqueIdentifier": "20202020-e89b-12d3-a456-426614174001",
},
"fieldMetadataUniqueIdentifier": "20202020-e89b-12d3-a456-426614174001",
"objectMetadataUniqueIdentifier": "20202020-e89b-12d3-a456-426614175001",
"type": "create_field",
},
],
}
`);
});
it('should return a migration when deleting an object', () => {
const result = service.build({ from: [baseObject], to: [] });
expect(result).toMatchInlineSnapshot(`
{
"actions": [
{
"objectMetadataUniqueIdentifier": "20202020-e89b-12d3-a456-426614175000",
"type": "delete_object",
},
],
}
`);
});
it('should handle multiple operations in a single migration', () => {
const objectToUpdate: WorkspaceMigrationObjectInput = {
...baseObject,
nameSingular: 'Person',
fields: [
...baseObject.fields,
{
defaultValue: '',
label: 'New field',
type: FieldMetadataType.NUMBER,
name: 'newField',
uniqueIdentifier: '20202020-3ad3-4fec-9c46-8dc9158980e3',
description: 'new field description',
},
],
};
const objectToDelete = {
...baseObject,
uniqueIdentifier: '20202020-59ef-4a14-a509-0a02acb248d5',
};
const objectToCreate: WorkspaceMigrationObjectInput = {
uniqueIdentifier: '20202020-1218-4fc0-b32d-fc4f005c4bab',
nameSingular: 'Company',
namePlural: 'Companies',
labelSingular: 'Company',
labelPlural: 'Companies',
description: 'A company',
fields: [
{
uniqueIdentifier: '20202020-1016-4f09-bad6-e75681f385f4',
name: 'name',
label: 'Name',
type: FieldMetadataType.ADDRESS,
defaultValue: '',
description: '',
},
],
};
const result = service.build({
from: [baseObject, objectToDelete],
to: [objectToUpdate, objectToCreate],
});
expect(result).toMatchInlineSnapshot(`
{
"actions": [
{
"object": {
"description": "A company",
"fields": [
{
"defaultValue": "",
"description": "",
"label": "Name",
"name": "name",
"type": "ADDRESS",
"uniqueIdentifier": "20202020-1016-4f09-bad6-e75681f385f4",
},
],
"labelPlural": "Companies",
"labelSingular": "Company",
"namePlural": "Companies",
"nameSingular": "Company",
"uniqueIdentifier": "20202020-1218-4fc0-b32d-fc4f005c4bab",
},
"objectMetadataUniqueIdentifier": "20202020-1218-4fc0-b32d-fc4f005c4bab",
"type": "create_object",
},
{
"field": {
"defaultValue": "",
"description": "",
"label": "Name",
"name": "name",
"type": "ADDRESS",
"uniqueIdentifier": "20202020-1016-4f09-bad6-e75681f385f4",
},
"fieldMetadataUniqueIdentifier": "20202020-1016-4f09-bad6-e75681f385f4",
"objectMetadataUniqueIdentifier": "20202020-1218-4fc0-b32d-fc4f005c4bab",
"type": "create_field",
},
{
"objectMetadataUniqueIdentifier": "20202020-59ef-4a14-a509-0a02acb248d5",
"type": "delete_object",
},
{
"objectMetadataUniqueIdentifier": "20202020-e89b-12d3-a456-426614175000",
"type": "update_object",
"updates": [
{
"from": "Contact",
"property": "nameSingular",
"to": "Person",
},
],
},
{
"field": {
"defaultValue": "",
"description": "new field description",
"label": "New field",
"name": "newField",
"type": "NUMBER",
"uniqueIdentifier": "20202020-3ad3-4fec-9c46-8dc9158980e3",
},
"fieldMetadataUniqueIdentifier": "20202020-3ad3-4fec-9c46-8dc9158980e3",
"objectMetadataUniqueIdentifier": "20202020-e89b-12d3-a456-426614175000",
"type": "create_field",
},
],
}
`);
});
it('should treat objects with the same name but different IDs as distinct', () => {
const objectA: WorkspaceMigrationObjectInput = {
uniqueIdentifier: 'id-1',
nameSingular: 'Duplicate',
namePlural: 'Duplicates',
labelSingular: 'Duplicate',
labelPlural: 'Duplicates',
description: 'First object',
fields: [
{
uniqueIdentifier: 'field-1',
name: 'fieldA',
label: 'Field A',
type: FieldMetadataType.FULL_NAME,
defaultValue: '',
description: '',
},
],
};
const objectB: WorkspaceMigrationObjectInput = {
uniqueIdentifier: 'id-2',
nameSingular: 'Duplicate',
namePlural: 'Duplicates',
labelSingular: 'Duplicate',
labelPlural: 'Duplicates',
description: 'Second object',
fields: [
{
uniqueIdentifier: 'field-2',
name: 'fieldB',
label: 'Field B',
type: FieldMetadataType.ADDRESS,
defaultValue: '',
description: '',
},
],
};
const result = service.build({ from: [], to: [objectA, objectB] });
expect(result.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'create_object',
objectMetadataUniqueIdentifier: 'id-1',
}),
expect.objectContaining({
type: 'create_object',
objectMetadataUniqueIdentifier: 'id-2',
}),
]),
);
const deleteResult = service.build({ from: [objectA, objectB], to: [] });
expect(deleteResult.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'delete_object',
objectMetadataUniqueIdentifier: 'id-1',
}),
expect.objectContaining({
type: 'delete_object',
objectMetadataUniqueIdentifier: 'id-2',
}),
]),
);
});
it('should emit no actions when from and to are deeply equal', () => {
const obj: WorkspaceMigrationObjectInput = { ...baseObject };
const result = service.build({ from: [obj], to: [obj] });
expect(result.actions).toEqual([]);
});
});

View File

@ -0,0 +1,56 @@
import { FromTo } from 'src/engine/workspace-manager/workspace-migration-v2/types/workspace-migration-action-v2';
export type DeletedCreatedUpdatedMatrix<T> = {
created: T[];
deleted: T[];
updated: FromTo<T>[];
};
export type CustomDeletedCreatedUpdatedMatrix<TLabel extends string, TInput> = {
[P in keyof DeletedCreatedUpdatedMatrix<TInput> as `${P}${Capitalize<TLabel>}`]: DeletedCreatedUpdatedMatrix<TInput>[P];
};
export type UniqueIdentifierItem = {
uniqueIdentifier: string;
};
export const deletedCreatedUpdatedMatrixDispatcher = <
T extends UniqueIdentifierItem,
>({
from,
to,
}: FromTo<T[]>): DeletedCreatedUpdatedMatrix<T> => {
const initialDispatcher: DeletedCreatedUpdatedMatrix<T> = {
created: [],
updated: [],
deleted: [],
};
const fromMap = new Map(from.map((obj) => [obj.uniqueIdentifier, obj]));
const toMap = new Map(to.map((obj) => [obj.uniqueIdentifier, obj]));
for (const [identifier, fromObj] of fromMap) {
if (!toMap.has(identifier)) {
initialDispatcher.deleted.push(fromObj);
}
}
for (const [identifier, toObj] of toMap) {
if (!fromMap.has(identifier)) {
initialDispatcher.created.push(toObj);
}
}
for (const [identifier, fromObj] of fromMap) {
const toObj = toMap.get(identifier);
if (toObj) {
initialDispatcher.updated.push({
from: fromObj,
to: toObj,
});
}
}
return initialDispatcher;
};

View File

@ -0,0 +1,29 @@
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import {
CreateFieldAction,
DeleteFieldAction,
} from 'src/engine/workspace-manager/workspace-migration-v2/types/workspace-migration-action-v2';
import { WorkspaceMigrationObjectFieldInput } from 'src/engine/workspace-manager/workspace-migration-v2/types/workspace-migration-object-input';
type FieldInputAndObjectUniqueIdentifier = {
field: WorkspaceMigrationObjectFieldInput;
objectMetadataUniqueIdentifier: string;
};
export const getWorkspaceMigrationV2FieldCreateAction = ({
field,
objectMetadataUniqueIdentifier,
}: FieldInputAndObjectUniqueIdentifier): CreateFieldAction => ({
type: 'create_field',
field: field as unknown as FieldMetadataEntity, // TODO prastoin
fieldMetadataUniqueIdentifier: field.uniqueIdentifier,
objectMetadataUniqueIdentifier,
});
export const getWorkspaceMigrationV2FieldDeleteAction = ({
field,
objectMetadataUniqueIdentifier,
}: FieldInputAndObjectUniqueIdentifier): DeleteFieldAction => ({
type: 'delete_field',
fieldMetadataUniqueIdentifier: field.uniqueIdentifier,
objectMetadataUniqueIdentifier,
});

View File

@ -0,0 +1,21 @@
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import {
CreateObjectAction,
DeleteObjectAction,
} from 'src/engine/workspace-manager/workspace-migration-v2/types/workspace-migration-action-v2';
import { WorkspaceMigrationObjectInput } from 'src/engine/workspace-manager/workspace-migration-v2/types/workspace-migration-object-input';
export const getWorkspaceMigrationV2ObjectCreateAction = (
input: WorkspaceMigrationObjectInput,
): CreateObjectAction => ({
type: 'create_object',
objectMetadataUniqueIdentifier: input.uniqueIdentifier,
object: input as unknown as ObjectMetadataEntity, // TODO prastoin
});
export const getWorkspaceMigrationV2ObjectDeleteAction = (
input: WorkspaceMigrationObjectInput,
): DeleteObjectAction => ({
type: 'delete_object',
objectMetadataUniqueIdentifier: input.uniqueIdentifier,
});

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { WorkspaceMigrationBuilderV2Service } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-builder-v2.service';
@Module({
imports: [FeatureFlagModule],
providers: [WorkspaceMigrationBuilderV2Service],
exports: [],
})
export class WorkspaceMigrationBuilderV2Module {}

View File

@ -0,0 +1,50 @@
import { Injectable } from '@nestjs/common';
import { WorkspaceMigrationObjectInput } from 'src/engine/workspace-manager/workspace-migration-v2/types/workspace-migration-object-input';
import { WorkspaceMigrationV2 } from 'src/engine/workspace-manager/workspace-migration-v2/types/workspace-migration-v2';
import {
DeletedCreatedUpdatedMatrix,
deletedCreatedUpdatedMatrixDispatcher,
} from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/utils/deleted-created-updated-matrix-dispatcher.util';
import { buildWorkspaceMigrationV2FieldActions } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-v2-field-actions-builder';
import { buildWorkspaceMigrationV2ObjectActions } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-v2-object-actions-builder';
type WorkspaceMigrationBuilderV2ServiceArgs = {
from: WorkspaceMigrationObjectInput[];
to: WorkspaceMigrationObjectInput[];
};
export type UniqueIdentifierWorkspaceMigrationObjectInputMapDispatcher =
DeletedCreatedUpdatedMatrix<WorkspaceMigrationObjectInput>;
@Injectable()
export class WorkspaceMigrationBuilderV2Service {
constructor() {}
build(
objectMetadataFromToInputs: WorkspaceMigrationBuilderV2ServiceArgs,
): WorkspaceMigrationV2 {
const {
created: createdObjectMetadata,
deleted: deletedObjectMetadata,
updated: updatedObjectMetadata,
} = deletedCreatedUpdatedMatrixDispatcher(objectMetadataFromToInputs);
const objectWorkspaceMigrationActions =
buildWorkspaceMigrationV2ObjectActions({
createdObjectMetadata,
deletedObjectMetadata,
updatedObjectMetadata,
});
const fieldWorkspaceMigrationActions =
buildWorkspaceMigrationV2FieldActions({ updatedObjectMetadata });
return {
actions: [
...objectWorkspaceMigrationActions,
...fieldWorkspaceMigrationActions,
],
};
}
}

View File

@ -0,0 +1,217 @@
import diff from 'microdiff';
import { FieldMetadataType } from 'twenty-shared/types';
import {
FromTo,
WorkspaceMigrationFieldActionV2,
} from 'src/engine/workspace-manager/workspace-migration-v2/types/workspace-migration-action-v2';
import { WorkspaceMigrationObjectFieldInput } from 'src/engine/workspace-manager/workspace-migration-v2/types/workspace-migration-object-input';
import {
getWorkspaceMigrationV2FieldCreateAction,
getWorkspaceMigrationV2FieldDeleteAction,
} from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/utils/get-workspace-migration-v2-field-actions';
import { UniqueIdentifierWorkspaceMigrationObjectInputMapDispatcher } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-builder-v2.service';
import { CreatedDeletedUpdatedObjectMetadataInputMatrix } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-v2-object-actions-builder';
import { transformMetadataForComparison } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util';
import {
CustomDeletedCreatedUpdatedMatrix,
deletedCreatedUpdatedMatrixDispatcher,
} from './utils/deleted-created-updated-matrix-dispatcher.util';
// Start TODO prastoin refactor and strictly type
const commonFieldPropertiesToIgnore = [
'id',
'createdAt',
'updatedAt',
'objectMetadataId',
'isActive',
'options',
'settings',
'joinColumn',
'gate',
'asExpression',
'generatedType',
'isLabelSyncedWithName',
// uniqueIdentifier ?
];
const shouldNotOverrideDefaultValue = (type: FieldMetadataType) => {
return [
FieldMetadataType.BOOLEAN,
FieldMetadataType.SELECT,
FieldMetadataType.MULTI_SELECT,
FieldMetadataType.CURRENCY,
FieldMetadataType.PHONES,
FieldMetadataType.ADDRESS,
].includes(type);
};
const fieldPropertiesToStringify = ['defaultValue'] as const;
/// End
export const compareTwoWorkspaceMigrationFieldInput = ({
from,
to,
}: FromTo<WorkspaceMigrationObjectFieldInput>) => {
const compareFieldMetadataOptions = {
shouldIgnoreProperty: (
property: string,
fieldMetadata: WorkspaceMigrationObjectFieldInput,
) => {
if (
property === 'defaultValue' &&
shouldNotOverrideDefaultValue(fieldMetadata.type)
) {
return true;
}
if (commonFieldPropertiesToIgnore.includes(property)) {
return true;
}
return false;
},
propertiesToStringify: fieldPropertiesToStringify,
};
const fromCompare = transformMetadataForComparison(
from,
compareFieldMetadataOptions,
);
const toCompare = transformMetadataForComparison(
to,
compareFieldMetadataOptions,
);
const fieldMetadataDifference = diff(fromCompare, toCompare);
return fieldMetadataDifference;
};
type BuildWorkspaceMigrationV2FieldActionFromUpdatedFieldMetadataArgs =
FromTo<WorkspaceMigrationObjectFieldInput> & {
objectMetadataUniqueIdentifier: string;
};
// Still in wip
const buildWorkspaceMigrationV2FieldActionFromUpdatedFieldMetadata = ({
objectMetadataUniqueIdentifier,
from,
to,
}: BuildWorkspaceMigrationV2FieldActionFromUpdatedFieldMetadataArgs) => {
const fieldMetadataDifferences = compareTwoWorkspaceMigrationFieldInput({
from,
to,
});
return fieldMetadataDifferences.flatMap<WorkspaceMigrationFieldActionV2>(
(difference) => {
switch (difference.type) {
case 'CREATE': {
return {
type: 'create_field',
field: difference.value,
fieldMetadataUniqueIdentifier: 'TODO',
objectMetadataUniqueIdentifier,
};
}
case 'CHANGE': {
// TODO prastoin
return [];
}
case 'REMOVE': {
return {
type: 'delete_field',
fieldMetadataUniqueIdentifier: difference.oldValue.uniqueIdentifier,
objectMetadataUniqueIdentifier,
};
}
default: {
return [];
}
}
},
);
};
type DeletedCreatedUpdatedFieldInputMatrix = {
objectMetadataUniqueIdentifier: string;
} & CustomDeletedCreatedUpdatedMatrix<
'fieldMetadata',
WorkspaceMigrationObjectFieldInput
>;
const updatedFieldMetadataMatriceMapDispatcher = (
updatedObjectMetadata: UniqueIdentifierWorkspaceMigrationObjectInputMapDispatcher['updated'],
): DeletedCreatedUpdatedFieldInputMatrix[] => {
const matriceAccumulator: DeletedCreatedUpdatedFieldInputMatrix[] = [];
for (const { from, to } of updatedObjectMetadata) {
const matrixResult = deletedCreatedUpdatedMatrixDispatcher({
from: from.fields,
to: to.fields,
});
matriceAccumulator.push({
objectMetadataUniqueIdentifier: from.uniqueIdentifier,
createdFieldMetadata: matrixResult.created,
deletedFieldMetadata: matrixResult.deleted,
updatedFieldMetadata: matrixResult.updated,
});
}
return matriceAccumulator;
};
type BuildWorkspaceMigrationV2FieldActionsArgs = Pick<
CreatedDeletedUpdatedObjectMetadataInputMatrix,
'updatedObjectMetadata'
>;
export const buildWorkspaceMigrationV2FieldActions = ({
updatedObjectMetadata,
}: BuildWorkspaceMigrationV2FieldActionsArgs): WorkspaceMigrationFieldActionV2[] => {
const objectMetadataDeletedCreatedUpdatedFields =
updatedFieldMetadataMatriceMapDispatcher(updatedObjectMetadata);
let allUpdatedObjectMetadataFieldActions: WorkspaceMigrationFieldActionV2[] =
[];
for (const {
createdFieldMetadata,
deletedFieldMetadata,
objectMetadataUniqueIdentifier,
updatedFieldMetadata,
} of objectMetadataDeletedCreatedUpdatedFields) {
const updateFieldAction =
updatedFieldMetadata.flatMap<WorkspaceMigrationFieldActionV2>(
({ from, to }) =>
buildWorkspaceMigrationV2FieldActionFromUpdatedFieldMetadata({
from,
to,
objectMetadataUniqueIdentifier: objectMetadataUniqueIdentifier,
}),
);
const createFieldAction = createdFieldMetadata.map((field) =>
getWorkspaceMigrationV2FieldCreateAction({
field,
objectMetadataUniqueIdentifier,
}),
);
const deleteFieldAction = deletedFieldMetadata.map((field) =>
getWorkspaceMigrationV2FieldDeleteAction({
field,
objectMetadataUniqueIdentifier,
}),
);
allUpdatedObjectMetadataFieldActions =
allUpdatedObjectMetadataFieldActions.concat([
...createFieldAction,
...deleteFieldAction,
...updateFieldAction,
]);
}
return allUpdatedObjectMetadataFieldActions;
};

View File

@ -0,0 +1,153 @@
import omit from 'lodash.omit';
import diff from 'microdiff';
import { assertUnreachable } from 'twenty-shared/utils';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import {
FromTo,
UpdateObjectAction,
WorkspaceMigrationActionV2,
} from 'src/engine/workspace-manager/workspace-migration-v2/types/workspace-migration-action-v2';
import { WorkspaceMigrationObjectInput } from 'src/engine/workspace-manager/workspace-migration-v2/types/workspace-migration-object-input';
import { CustomDeletedCreatedUpdatedMatrix } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/utils/deleted-created-updated-matrix-dispatcher.util';
import { getWorkspaceMigrationV2FieldCreateAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/utils/get-workspace-migration-v2-field-actions';
import {
getWorkspaceMigrationV2ObjectCreateAction,
getWorkspaceMigrationV2ObjectDeleteAction,
} from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/utils/get-workspace-migration-v2-object-actions';
import { transformMetadataForComparison } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util';
// Start TODO prastoin refactor and strictly type
const objectPropertiesToIgnore = [
'id',
'createdAt',
'updatedAt',
'labelIdentifierFieldMetadataId',
'imageIdentifierFieldMetadataId',
'isActive',
'fields',
];
// Not the same for standard and custom
const allowedObjectProps: (keyof Partial<ObjectMetadataEntity>)[] = [
'nameSingular',
'namePlural',
'labelSingular',
'labelPlural',
'description',
];
/// End
type ObjectWorkspaceMigrationUpdate = FromTo<WorkspaceMigrationObjectInput>;
const compareTwoWorkspaceMigrationObjectInput = ({
from,
to,
}: ObjectWorkspaceMigrationUpdate) => {
const fromCompare = transformMetadataForComparison(from, {
shouldIgnoreProperty: (property) =>
objectPropertiesToIgnore.includes(property),
});
const toCompare = transformMetadataForComparison(to, {
shouldIgnoreProperty: (property) =>
objectPropertiesToIgnore.includes(property),
});
const objectMetadataDifference = diff(fromCompare, omit(toCompare, 'fields'));
return objectMetadataDifference.flatMap<
UpdateObjectAction['updates'][number]
>((difference) => {
switch (difference.type) {
case 'CHANGE': {
if (
difference.oldValue === null &&
(difference.value === null || difference.value === undefined)
) {
return [];
}
const property = difference.path[0];
// TODO investigate why it would be a number, in case of array I guess ?
if (typeof property === 'number') {
return [];
}
// Could be handled directly from the diff we do above
if (
!allowedObjectProps.includes(property as keyof ObjectMetadataEntity)
) {
return [];
}
return {
property,
from: difference.oldValue,
to: difference.value,
};
}
case 'CREATE':
case 'REMOVE': {
// Should never occurs ? should throw ?
return [];
}
default: {
assertUnreachable(difference, 'TODO');
}
}
});
};
export type CreatedDeletedUpdatedObjectMetadataInputMatrix =
CustomDeletedCreatedUpdatedMatrix<
'objectMetadata',
WorkspaceMigrationObjectInput
>;
export const buildWorkspaceMigrationV2ObjectActions = ({
createdObjectMetadata,
deletedObjectMetadata,
updatedObjectMetadata,
}: CreatedDeletedUpdatedObjectMetadataInputMatrix): WorkspaceMigrationActionV2[] => {
const createdObjectActions = createdObjectMetadata.flatMap(
(objectMetadata) => {
const createObjectAction =
getWorkspaceMigrationV2ObjectCreateAction(objectMetadata);
const createFieldActions = objectMetadata.fields.map((field) =>
getWorkspaceMigrationV2FieldCreateAction({
field,
objectMetadataUniqueIdentifier: objectMetadata.uniqueIdentifier,
}),
);
return [createObjectAction, ...createFieldActions];
},
);
const deletedObjectActions = deletedObjectMetadata.map(
getWorkspaceMigrationV2ObjectDeleteAction,
);
const updatedObjectActions = updatedObjectMetadata
.map<UpdateObjectAction | null>(({ from, to }) => {
const objectUpdatedProperties = compareTwoWorkspaceMigrationObjectInput({
from,
to,
});
if (objectUpdatedProperties.length === 0) {
return null;
}
return {
objectMetadataUniqueIdentifier: from.uniqueIdentifier,
type: 'update_object',
updates: objectUpdatedProperties,
};
})
.filter((action): action is UpdateObjectAction => action !== null);
return [
...createdObjectActions,
...deletedObjectActions,
...updatedObjectActions,
];
};

View File

@ -0,0 +1,6 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class WorkspaceMetadataMigrationRunnerService {
constructor() {}
}

View File

@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { WorkspaceMetadataMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-metadata-migration-runner/workspace-metadata-migration-runner.service';
import { WorkspaceSchemaMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-schema-migration-runner/workspace-schema-migration-runner.service';
@Module({
imports: [FeatureFlagModule],
providers: [
WorkspaceMetadataMigrationRunnerService,
WorkspaceSchemaMigrationRunnerService,
],
exports: [],
})
export class WorkspaceMigrationRunnerV2Module {}

View File

@ -0,0 +1,6 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class WorkspaceSchemaMigrationRunnerService {
constructor() {}
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { WorkspaceMigrationBuilderV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-builder-v2.module';
import { WorkspaceMigrationRunnerV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-migration-runner-v2.module';
@Module({
imports: [
WorkspaceMigrationBuilderV2Module,
WorkspaceMigrationRunnerV2Module,
],
providers: [],
exports: [],
})
export class WorkspaceMigrationV2Module {}