Remove sync-metadata and IS_WORKSPACE_CREATION_V2_ENABLED feature flag (#16997)

# Introduction
Followup of
https://github.com/twentyhq/twenty/pull/17001#pullrequestreview-3638508738
close https://github.com/twentyhq/core-team-issues/issues/1910

We've completely decom the `sync-metadata` in production. We're now then
removing its implementation in favor of the v2.

## TODO:
- [x] Remove sync-metadata implem and commands
- [x] Remove workspace decorators 
- [x] Type each deprecated field to deprecated on their workspaceEntity
- [x] Remove the `workspace-sync-metadata` folder entirely
- [x] remove workspace migration
- [x] workspace migration removal migration
- [x] remove the `v2` references from workspace manager file names
- [x] remove the `v2` references from workspace manager modules
- [ ] Double check impact on translation file path updates


## Note
- Removed the gate logic
- Remains some service v2 naming, serverless needs to be migrated on v2
fully
- Removed workspaceMigration service app health consumption, making it
always returning up ( no more down ) cc @FelixMalfait ( quite obsolete
health check now, will require complete refactor once we introduce inter
app dependency etc )
This commit is contained in:
Paul Rastoin
2026-01-08 16:45:12 +01:00
committed by GitHub
parent f1aee7fd18
commit 942d2fef83
629 changed files with 1985 additions and 21492 deletions
@@ -0,0 +1,275 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect a created entity 1`] = `
{
"createdFlatEntityMaps": {
"byId": {
"field-id-1": {
"applicationId": "application-id-1",
"calendarViewIds": [],
"createdAt": "2024-01-01T00:00:00.000Z",
"defaultValue": null,
"description": "default flat field metadata description",
"icon": "icon",
"id": "field-id-1",
"isActive": true,
"isCustom": true,
"isLabelSyncedWithName": false,
"isNullable": true,
"isSystem": false,
"isUIReadOnly": false,
"isUnique": false,
"kanbanAggregateOperationViewIds": [],
"label": "flat field metadata label",
"mainGroupByFieldMetadataViewIds": [],
"morphId": null,
"name": "flatFieldMetadataName",
"objectMetadataId": "object-metadata-id-1",
"options": null,
"relationTargetFieldMetadataId": null,
"relationTargetObjectMetadataId": null,
"settings": null,
"standardId": null,
"standardOverrides": null,
"type": "TEXT",
"universalIdentifier": "universal-identifier-1",
"updatedAt": "2024-01-01T00:00:00.000Z",
"viewFieldIds": [],
"viewFilterIds": [],
"workspaceId": "workspace-id-1",
},
},
"idByUniversalIdentifier": {
"universal-identifier-1": "field-id-1",
},
"universalIdentifiersByApplicationId": {
"application-id-1": [
"universal-identifier-1",
],
},
},
"deletedFlatEntityMaps": {
"byId": {},
"idByUniversalIdentifier": {},
"universalIdentifiersByApplicationId": {},
},
"updatedFlatEntityMaps": {
"byId": {},
},
}
`;
exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect a deleted entity 1`] = `
{
"createdFlatEntityMaps": {
"byId": {},
"idByUniversalIdentifier": {},
"universalIdentifiersByApplicationId": {},
},
"deletedFlatEntityMaps": {
"byId": {
"field-id-1": {
"applicationId": "application-id-1",
"calendarViewIds": [],
"createdAt": "2024-01-01T00:00:00.000Z",
"defaultValue": null,
"description": "default flat field metadata description",
"icon": "icon",
"id": "field-id-1",
"isActive": true,
"isCustom": true,
"isLabelSyncedWithName": false,
"isNullable": true,
"isSystem": false,
"isUIReadOnly": false,
"isUnique": false,
"kanbanAggregateOperationViewIds": [],
"label": "flat field metadata label",
"mainGroupByFieldMetadataViewIds": [],
"morphId": null,
"name": "flatFieldMetadataName",
"objectMetadataId": "object-metadata-id-1",
"options": null,
"relationTargetFieldMetadataId": null,
"relationTargetObjectMetadataId": null,
"settings": null,
"standardId": null,
"standardOverrides": null,
"type": "TEXT",
"universalIdentifier": "universal-identifier-1",
"updatedAt": "2024-01-01T00:00:00.000Z",
"viewFieldIds": [],
"viewFilterIds": [],
"workspaceId": "workspace-id-1",
},
},
"idByUniversalIdentifier": {
"universal-identifier-1": "field-id-1",
},
"universalIdentifiersByApplicationId": {
"application-id-1": [
"universal-identifier-1",
],
},
},
"updatedFlatEntityMaps": {
"byId": {},
},
}
`;
exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect an updated entity 1`] = `
{
"createdFlatEntityMaps": {
"byId": {},
"idByUniversalIdentifier": {},
"universalIdentifiersByApplicationId": {},
},
"deletedFlatEntityMaps": {
"byId": {},
"idByUniversalIdentifier": {},
"universalIdentifiersByApplicationId": {},
},
"updatedFlatEntityMaps": {
"byId": {
"field-id-1": {
"updates": [
{
"from": false,
"property": "isActive",
"to": true,
},
],
},
},
},
}
`;
exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect created, deleted and updated entities 1`] = `
{
"createdFlatEntityMaps": {
"byId": {
"field-id-3": {
"applicationId": "application-id-1",
"calendarViewIds": [],
"createdAt": "2024-01-01T00:00:00.000Z",
"defaultValue": null,
"description": "default flat field metadata description",
"icon": "icon",
"id": "field-id-3",
"isActive": true,
"isCustom": true,
"isLabelSyncedWithName": false,
"isNullable": true,
"isSystem": false,
"isUIReadOnly": false,
"isUnique": false,
"kanbanAggregateOperationViewIds": [],
"label": "flat field metadata label",
"mainGroupByFieldMetadataViewIds": [],
"morphId": null,
"name": "flatFieldMetadataName",
"objectMetadataId": "object-metadata-id-1",
"options": null,
"relationTargetFieldMetadataId": null,
"relationTargetObjectMetadataId": null,
"settings": null,
"standardId": null,
"standardOverrides": null,
"type": "TEXT",
"universalIdentifier": "universal-identifier-3",
"updatedAt": "2024-01-01T00:00:00.000Z",
"viewFieldIds": [],
"viewFilterIds": [],
"workspaceId": "workspace-id-1",
},
},
"idByUniversalIdentifier": {
"universal-identifier-3": "field-id-3",
},
"universalIdentifiersByApplicationId": {
"application-id-1": [
"universal-identifier-3",
],
},
},
"deletedFlatEntityMaps": {
"byId": {
"field-id-2": {
"applicationId": "application-id-1",
"calendarViewIds": [],
"createdAt": "2024-01-01T00:00:00.000Z",
"defaultValue": null,
"description": "default flat field metadata description",
"icon": "icon",
"id": "field-id-2",
"isActive": true,
"isCustom": true,
"isLabelSyncedWithName": false,
"isNullable": true,
"isSystem": false,
"isUIReadOnly": false,
"isUnique": false,
"kanbanAggregateOperationViewIds": [],
"label": "flat field metadata label",
"mainGroupByFieldMetadataViewIds": [],
"morphId": null,
"name": "flatFieldMetadataName",
"objectMetadataId": "object-metadata-id-1",
"options": null,
"relationTargetFieldMetadataId": null,
"relationTargetObjectMetadataId": null,
"settings": null,
"standardId": null,
"standardOverrides": null,
"type": "TEXT",
"universalIdentifier": "universal-identifier-2",
"updatedAt": "2024-01-01T00:00:00.000Z",
"viewFieldIds": [],
"viewFilterIds": [],
"workspaceId": "workspace-id-1",
},
},
"idByUniversalIdentifier": {
"universal-identifier-2": "field-id-2",
},
"universalIdentifiersByApplicationId": {
"application-id-1": [
"universal-identifier-2",
],
},
},
"updatedFlatEntityMaps": {
"byId": {
"field-id-1": {
"updates": [
{
"from": true,
"property": "isActive",
"to": false,
},
],
},
},
},
}
`;
exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should not detect deleted entities when inferDeletionFromMissingEntities is false 1`] = `
{
"createdFlatEntityMaps": {
"byId": {},
"idByUniversalIdentifier": {},
"universalIdentifiersByApplicationId": {},
},
"deletedFlatEntityMaps": {
"byId": {},
"idByUniversalIdentifier": {},
"universalIdentifiersByApplicationId": {},
},
"updatedFlatEntityMaps": {
"byId": {},
},
}
`;
@@ -0,0 +1,491 @@
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock';
import { getFlatObjectMetadataMock } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock';
import { createEmptyOrchestratorActionsReport } from 'src/engine/workspace-manager/workspace-migration/constant/empty-orchestrator-actions-report.constant';
import { type OrchestratorActionsReport } from 'src/engine/workspace-manager/workspace-migration/types/workspace-migration-orchestrator.type';
import { aggregateOrchestratorActionsReportCreateObjectAndCreateFieldActions } from 'src/engine/workspace-manager/workspace-migration/utils/aggregate-orchestrator-actions-report-create-object-and-create-field-actions.util';
import { type CreateFieldAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/types/workspace-migration-field-action';
import { type CreateObjectAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/object/types/workspace-migration-object-action';
type CreateAggregationTestCase = EachTestingContext<{
input: OrchestratorActionsReport;
expected: {
expectCreateFieldActionPerObjectMetadataId: Record<string, number>;
expectCreateObjectActionPerObjectMetadataId: Record<string, number>;
};
}>;
describe('aggregateOrchestratorActionsReportCreateObjectAndCreateFieldActions', () => {
const testCases: CreateAggregationTestCase[] = [
{
title:
'should aggregate single object with multiple fields into one object action',
context: {
input: {
...createEmptyOrchestratorActionsReport(),
objectMetadata: {
create: [
{
type: 'create',
metadataName: 'objectMetadata',
flatEntity: getFlatObjectMetadataMock({
universalIdentifier: 'object-1',
id: 'object-1',
nameSingular: 'user',
namePlural: 'users',
}),
flatFieldMetadatas: [],
} satisfies CreateObjectAction,
],
update: [],
delete: [],
},
fieldMetadata: {
create: [
{
type: 'create',
metadataName: 'fieldMetadata',
objectMetadataId: 'object-1',
flatFieldMetadatas: [
getFlatFieldMetadataMock({
universalIdentifier: 'field-1',
objectMetadataId: 'object-1',
type: FieldMetadataType.TEXT,
id: 'field-1',
name: 'firstName',
}),
getFlatFieldMetadataMock({
universalIdentifier: 'field-2',
objectMetadataId: 'object-1',
type: FieldMetadataType.TEXT,
id: 'field-2',
name: 'lastName',
}),
],
} satisfies CreateFieldAction,
],
update: [],
delete: [],
},
} satisfies OrchestratorActionsReport,
expected: {
expectCreateFieldActionPerObjectMetadataId: {},
expectCreateObjectActionPerObjectMetadataId: {
'object-1': 1,
},
},
},
},
{
title:
'should keep separate field actions when no matching object action exists',
context: {
input: {
...createEmptyOrchestratorActionsReport(),
objectMetadata: {
create: [],
update: [],
delete: [],
},
fieldMetadata: {
create: [
{
type: 'create',
metadataName: 'fieldMetadata',
objectMetadataId: 'object-1',
flatFieldMetadatas: [
getFlatFieldMetadataMock({
universalIdentifier: 'field-1',
objectMetadataId: 'object-1',
type: FieldMetadataType.TEXT,
id: 'field-1',
name: 'firstName',
}),
],
} satisfies CreateFieldAction,
{
type: 'create',
metadataName: 'fieldMetadata',
objectMetadataId: 'object-1',
flatFieldMetadatas: [
getFlatFieldMetadataMock({
universalIdentifier: 'field-2',
objectMetadataId: 'object-1',
type: FieldMetadataType.TEXT,
id: 'field-2',
name: 'secondName',
}),
],
} satisfies CreateFieldAction,
{
type: 'create',
metadataName: 'fieldMetadata',
objectMetadataId: 'object-1',
flatFieldMetadatas: [
getFlatFieldMetadataMock({
universalIdentifier: 'field-3',
objectMetadataId: 'object-1',
type: FieldMetadataType.TEXT,
id: 'field-3',
name: 'lastName',
}),
],
} satisfies CreateFieldAction,
],
update: [],
delete: [],
},
} satisfies OrchestratorActionsReport,
expected: {
expectCreateFieldActionPerObjectMetadataId: {
'object-1': 1,
},
expectCreateObjectActionPerObjectMetadataId: {},
},
},
},
{
title: 'should handle multiple objects with their respective fields',
context: {
input: {
...createEmptyOrchestratorActionsReport(),
objectMetadata: {
create: [
{
type: 'create',
metadataName: 'objectMetadata',
flatEntity: getFlatObjectMetadataMock({
universalIdentifier: 'object-1',
id: 'object-1',
nameSingular: 'user',
namePlural: 'users',
}),
flatFieldMetadatas: [],
} satisfies CreateObjectAction,
{
type: 'create',
metadataName: 'objectMetadata',
flatEntity: getFlatObjectMetadataMock({
universalIdentifier: 'object-2',
id: 'object-2',
nameSingular: 'company',
namePlural: 'companies',
}),
flatFieldMetadatas: [],
} satisfies CreateObjectAction,
],
update: [],
delete: [],
},
fieldMetadata: {
create: [
{
type: 'create',
metadataName: 'fieldMetadata',
objectMetadataId: 'object-1',
flatFieldMetadatas: [
getFlatFieldMetadataMock({
universalIdentifier: 'field-1',
objectMetadataId: 'object-1',
type: FieldMetadataType.TEXT,
id: 'field-1',
name: 'firstName',
}),
],
} satisfies CreateFieldAction,
{
type: 'create',
metadataName: 'fieldMetadata',
objectMetadataId: 'object-2',
flatFieldMetadatas: [
getFlatFieldMetadataMock({
universalIdentifier: 'field-2',
objectMetadataId: 'object-2',
type: FieldMetadataType.TEXT,
id: 'field-2',
name: 'name',
}),
getFlatFieldMetadataMock({
universalIdentifier: 'field-3',
objectMetadataId: 'object-2',
type: FieldMetadataType.TEXT,
id: 'field-3',
name: 'industry',
}),
],
} satisfies CreateFieldAction,
],
update: [],
delete: [],
},
} satisfies OrchestratorActionsReport,
expected: {
expectCreateFieldActionPerObjectMetadataId: {},
expectCreateObjectActionPerObjectMetadataId: {
'object-1': 1,
'object-2': 1,
},
},
},
},
{
title:
'should handle mixed scenario with some fields merged and some kept separate',
context: {
input: {
...createEmptyOrchestratorActionsReport(),
objectMetadata: {
create: [
{
type: 'create',
metadataName: 'objectMetadata',
flatEntity: getFlatObjectMetadataMock({
universalIdentifier: 'object-1',
id: 'object-1',
nameSingular: 'user',
namePlural: 'users',
}),
flatFieldMetadatas: [],
} satisfies CreateObjectAction,
],
update: [],
delete: [],
},
fieldMetadata: {
create: [
{
type: 'create',
metadataName: 'fieldMetadata',
objectMetadataId: 'object-1',
flatFieldMetadatas: [
getFlatFieldMetadataMock({
universalIdentifier: 'field-1',
objectMetadataId: 'object-1',
type: FieldMetadataType.TEXT,
id: 'field-1',
name: 'firstName',
}),
],
} satisfies CreateFieldAction,
{
type: 'create',
metadataName: 'fieldMetadata',
objectMetadataId: 'object-2',
flatFieldMetadatas: [
getFlatFieldMetadataMock({
universalIdentifier: 'field-2',
objectMetadataId: 'object-2',
type: FieldMetadataType.TEXT,
id: 'field-2',
name: 'orphanField',
}),
],
} satisfies CreateFieldAction,
],
update: [],
delete: [],
},
} satisfies OrchestratorActionsReport,
expected: {
expectCreateFieldActionPerObjectMetadataId: {
'object-2': 1,
},
expectCreateObjectActionPerObjectMetadataId: {
'object-1': 1,
},
},
},
},
{
title:
'should aggregate multiple field actions for the same object when no create object action exists',
context: {
input: {
...createEmptyOrchestratorActionsReport(),
objectMetadata: {
create: [],
update: [],
delete: [],
},
fieldMetadata: {
create: [
{
type: 'create',
metadataName: 'fieldMetadata',
objectMetadataId: 'object-1',
flatFieldMetadatas: [
getFlatFieldMetadataMock({
universalIdentifier: 'field-1',
objectMetadataId: 'object-1',
type: FieldMetadataType.TEXT,
id: 'field-1',
name: 'firstName',
}),
],
} satisfies CreateFieldAction,
{
type: 'create',
metadataName: 'fieldMetadata',
objectMetadataId: 'object-1',
flatFieldMetadatas: [
getFlatFieldMetadataMock({
universalIdentifier: 'field-2',
objectMetadataId: 'object-1',
type: FieldMetadataType.TEXT,
id: 'field-2',
name: 'lastName',
}),
],
} satisfies CreateFieldAction,
{
type: 'create',
metadataName: 'fieldMetadata',
objectMetadataId: 'object-1',
flatFieldMetadatas: [
getFlatFieldMetadataMock({
universalIdentifier: 'field-3',
objectMetadataId: 'object-1',
type: FieldMetadataType.TEXT,
id: 'field-3',
name: 'email',
}),
],
} satisfies CreateFieldAction,
],
update: [],
delete: [],
},
} satisfies OrchestratorActionsReport,
expected: {
expectCreateFieldActionPerObjectMetadataId: {
'object-1': 1,
},
expectCreateObjectActionPerObjectMetadataId: {},
},
},
},
{
title: 'should handle empty actions report',
context: {
input: createEmptyOrchestratorActionsReport(),
expected: {
expectCreateFieldActionPerObjectMetadataId: {},
expectCreateObjectActionPerObjectMetadataId: {},
},
},
},
{
title:
'should not merge create_field actions containing relation fields into create_object',
context: {
input: {
...createEmptyOrchestratorActionsReport(),
objectMetadata: {
create: [
{
type: 'create',
metadataName: 'objectMetadata',
flatEntity: getFlatObjectMetadataMock({
universalIdentifier: 'object-1',
id: 'object-1',
nameSingular: 'attachment',
namePlural: 'attachments',
}),
flatFieldMetadatas: [],
} satisfies CreateObjectAction,
],
update: [],
delete: [],
},
fieldMetadata: {
create: [
{
type: 'create',
metadataName: 'fieldMetadata',
objectMetadataId: 'object-1',
flatFieldMetadatas: [
getFlatFieldMetadataMock({
universalIdentifier: 'field-1',
objectMetadataId: 'object-1',
type: FieldMetadataType.RELATION,
id: 'field-1',
name: 'author',
}),
],
} satisfies CreateFieldAction,
],
update: [],
delete: [],
},
} satisfies OrchestratorActionsReport,
expected: {
expectCreateFieldActionPerObjectMetadataId: {
'object-1': 1,
},
expectCreateObjectActionPerObjectMetadataId: {
'object-1': 1,
},
},
},
},
];
test.each(eachTestingContextFilter(testCases))(
'$title',
({ context: { input, expected } }) => {
const result =
aggregateOrchestratorActionsReportCreateObjectAndCreateFieldActions({
orchestratorActionsReport: input,
});
const fieldActions = result.fieldMetadata.create as CreateFieldAction[];
const fieldActionCounts = fieldActions.reduce(
(acc, action) => {
acc[action.objectMetadataId] =
(acc[action.objectMetadataId] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
const objectActions = result.objectMetadata
.create as CreateObjectAction[];
const objectActionCounts = objectActions.reduce(
(acc, action) => {
acc[action.flatEntity.id] = (acc[action.flatEntity.id] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
Object.entries(
expected.expectCreateFieldActionPerObjectMetadataId,
).forEach(([objectId, expectedCount]) => {
expect(fieldActionCounts[objectId]).toBe(expectedCount);
});
Object.entries(
expected.expectCreateObjectActionPerObjectMetadataId,
).forEach(([objectId, expectedCount]) => {
expect(objectActionCounts[objectId]).toBe(expectedCount);
});
// Check total counts
const expectedTotalFieldActions = Object.values(
expected.expectCreateFieldActionPerObjectMetadataId,
).reduce((sum, count) => sum + count, 0);
const expectedTotalObjectActions = Object.values(
expected.expectCreateObjectActionPerObjectMetadataId,
).reduce((sum, count) => sum + count, 0);
expect(fieldActions).toHaveLength(expectedTotalFieldActions);
expect(objectActions).toHaveLength(expectedTotalObjectActions);
},
);
});
@@ -0,0 +1,265 @@
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import { createEmptyOrchestratorActionsReport } from 'src/engine/workspace-manager/workspace-migration/constant/empty-orchestrator-actions-report.constant';
import { type OrchestratorActionsReport } from 'src/engine/workspace-manager/workspace-migration/types/workspace-migration-orchestrator.type';
import { aggregateOrchestratorActionsReportDeleteObjectAndDeleteFieldActions } from 'src/engine/workspace-manager/workspace-migration/utils/aggregate-orchestrator-actions-report-delete-object-and-delete-field.util';
import { type DeleteFieldAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/types/workspace-migration-field-action';
import { type DeleteObjectAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/object/types/workspace-migration-object-action';
type DeleteAggregationTestCase = EachTestingContext<{
input: OrchestratorActionsReport;
expected: {
expectDeleteFieldActionPerObjectMetadataId: Record<string, number>;
expectDeleteObjectActionPerObjectMetadataId: Record<string, number>;
};
}>;
describe('aggregateOrchestratorActionsReportDeleteObjectAndDeleteFieldActions', () => {
const testCases: DeleteAggregationTestCase[] = [
{
title: 'should remove field actions when parent object is being deleted',
context: {
input: {
...createEmptyOrchestratorActionsReport(),
objectMetadata: {
create: [],
update: [],
delete: [
{
type: 'delete',
metadataName: 'objectMetadata',
entityId: 'object-1',
} satisfies DeleteObjectAction,
],
},
fieldMetadata: {
create: [],
update: [],
delete: [
{
type: 'delete',
metadataName: 'fieldMetadata',
entityId: 'field-1',
objectMetadataId: 'object-1',
} satisfies DeleteFieldAction,
{
type: 'delete',
metadataName: 'fieldMetadata',
entityId: 'field-2',
objectMetadataId: 'object-1',
} satisfies DeleteFieldAction,
],
},
} satisfies OrchestratorActionsReport,
expected: {
expectDeleteFieldActionPerObjectMetadataId: {},
expectDeleteObjectActionPerObjectMetadataId: {
'object-1': 1,
},
},
},
},
{
title: 'should keep field actions when no parent object is being deleted',
context: {
input: {
...createEmptyOrchestratorActionsReport(),
objectMetadata: {
create: [],
update: [],
delete: [],
},
fieldMetadata: {
create: [],
update: [],
delete: [
{
type: 'delete',
metadataName: 'fieldMetadata',
entityId: 'field-1',
objectMetadataId: 'object-1',
} satisfies DeleteFieldAction,
],
},
} satisfies OrchestratorActionsReport,
expected: {
expectDeleteFieldActionPerObjectMetadataId: {
'object-1': 1,
},
expectDeleteObjectActionPerObjectMetadataId: {},
},
},
},
{
title:
'should handle mixed scenario with some fields removed and some kept',
context: {
input: {
...createEmptyOrchestratorActionsReport(),
objectMetadata: {
create: [],
update: [],
delete: [
{
type: 'delete',
metadataName: 'objectMetadata',
entityId: 'object-1',
} satisfies DeleteObjectAction,
],
},
fieldMetadata: {
create: [],
update: [],
delete: [
{
type: 'delete',
metadataName: 'fieldMetadata',
entityId: 'field-1',
objectMetadataId: 'object-1',
} satisfies DeleteFieldAction,
{
type: 'delete',
metadataName: 'fieldMetadata',
entityId: 'field-2',
objectMetadataId: 'object-2',
} satisfies DeleteFieldAction,
],
},
} satisfies OrchestratorActionsReport,
expected: {
expectDeleteFieldActionPerObjectMetadataId: {
'object-2': 1,
},
expectDeleteObjectActionPerObjectMetadataId: {
'object-1': 1,
},
},
},
},
{
title: 'should handle multiple objects with mixed field deletions',
context: {
input: {
...createEmptyOrchestratorActionsReport(),
objectMetadata: {
create: [],
update: [],
delete: [
{
type: 'delete',
metadataName: 'objectMetadata',
entityId: 'object-1',
} satisfies DeleteObjectAction,
{
type: 'delete',
metadataName: 'objectMetadata',
entityId: 'object-2',
} satisfies DeleteObjectAction,
],
},
fieldMetadata: {
create: [],
update: [],
delete: [
{
type: 'delete',
metadataName: 'fieldMetadata',
entityId: 'field-1',
objectMetadataId: 'object-1',
} satisfies DeleteFieldAction,
{
type: 'delete',
metadataName: 'fieldMetadata',
entityId: 'field-2',
objectMetadataId: 'object-2',
} satisfies DeleteFieldAction,
{
type: 'delete',
metadataName: 'fieldMetadata',
entityId: 'field-3',
objectMetadataId: 'object-3',
} satisfies DeleteFieldAction,
],
},
} satisfies OrchestratorActionsReport,
expected: {
expectDeleteFieldActionPerObjectMetadataId: {
'object-3': 1,
},
expectDeleteObjectActionPerObjectMetadataId: {
'object-1': 1,
'object-2': 1,
},
},
},
},
{
title: 'should handle empty actions report',
context: {
input: createEmptyOrchestratorActionsReport(),
expected: {
expectDeleteFieldActionPerObjectMetadataId: {},
expectDeleteObjectActionPerObjectMetadataId: {},
},
},
},
];
test.each(eachTestingContextFilter(testCases))(
'$title',
({ context: { input, expected } }) => {
const result =
aggregateOrchestratorActionsReportDeleteObjectAndDeleteFieldActions({
orchestratorActionsReport: input,
});
const fieldActions = result.fieldMetadata.delete as DeleteFieldAction[];
const fieldActionCounts = fieldActions.reduce(
(acc, action) => {
acc[action.objectMetadataId] =
(acc[action.objectMetadataId] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
const objectActions = result.objectMetadata
.delete as DeleteObjectAction[];
const objectActionCounts = objectActions.reduce(
(acc, action) => {
acc[action.entityId] = (acc[action.entityId] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
Object.entries(
expected.expectDeleteFieldActionPerObjectMetadataId,
).forEach(([objectId, expectedCount]) => {
expect(fieldActionCounts[objectId]).toBe(expectedCount);
});
Object.entries(
expected.expectDeleteObjectActionPerObjectMetadataId,
).forEach(([objectId, expectedCount]) => {
expect(objectActionCounts[objectId]).toBe(expectedCount);
});
// Check total counts
const expectedTotalFieldActions = Object.values(
expected.expectDeleteFieldActionPerObjectMetadataId,
).reduce((sum, count) => sum + count, 0);
const expectedTotalObjectActions = Object.values(
expected.expectDeleteObjectActionPerObjectMetadataId,
).reduce((sum, count) => sum + count, 0);
expect(fieldActions).toHaveLength(expectedTotalFieldActions);
expect(objectActions).toHaveLength(expectedTotalObjectActions);
},
);
});
@@ -0,0 +1,124 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-flat-entity-maps-or-throw.util';
import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/search-field-metadata/constants/search-vector-field.constants';
import { createEmptyOrchestratorActionsReport } from 'src/engine/workspace-manager/workspace-migration/constant/empty-orchestrator-actions-report.constant';
import { aggregateOrchestratorActionsReportDeprioritizeSearchVectorUpdateFieldActions } from 'src/engine/workspace-manager/workspace-migration/utils/aggregate-orchestrator-actions-report-deprioritize-search-vector-update-field-actions.util';
import { type UpdateFieldAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/types/workspace-migration-field-action';
describe('aggregateOrchestratorActionsReportDeprioritizeSearchVectorUpdateFieldActions', () => {
it('should move searchVector update actions to the end of the update list', () => {
const orchestratorActionsReport = {
...createEmptyOrchestratorActionsReport(),
fieldMetadata: {
create: [],
update: [
{
type: 'update',
metadataName: 'fieldMetadata',
entityId: 'search-vector-field-1',
objectMetadataId: 'object-1',
updates: [
{
property: 'label',
from: 'Search Vector',
to: 'Updated Search Vector',
},
],
} satisfies UpdateFieldAction,
{
type: 'update',
metadataName: 'fieldMetadata',
entityId: 'regular-field-1',
objectMetadataId: 'object-1',
updates: [
{
property: 'label',
from: 'First Name',
to: 'Updated First Name',
},
],
} satisfies UpdateFieldAction,
{
type: 'update',
metadataName: 'fieldMetadata',
entityId: 'regular-field-2',
objectMetadataId: 'object-1',
updates: [
{
property: 'label',
from: 'Last Name',
to: 'Updated Last Name',
},
],
} satisfies UpdateFieldAction,
],
delete: [],
},
};
const flatFieldMetadataMaps = [
getFlatFieldMetadataMock({
universalIdentifier: 'search-vector-field-1',
objectMetadataId: 'object-1',
type: FieldMetadataType.TS_VECTOR,
id: 'search-vector-field-1',
name: SEARCH_VECTOR_FIELD.name,
}),
getFlatFieldMetadataMock({
universalIdentifier: 'regular-field-1',
objectMetadataId: 'object-1',
type: FieldMetadataType.TEXT,
id: 'regular-field-1',
name: 'firstName',
}),
getFlatFieldMetadataMock({
universalIdentifier: 'regular-field-2',
objectMetadataId: 'object-1',
type: FieldMetadataType.TEXT,
id: 'regular-field-2',
name: 'lastName',
}),
].reduce<FlatEntityMaps<FlatFieldMetadata>>(
(flatEntityMaps, field) =>
addFlatEntityToFlatEntityMapsOrThrow({
flatEntity: field,
flatEntityMaps,
}),
createEmptyFlatEntityMaps() as FlatEntityMaps<FlatFieldMetadata>,
);
const result =
aggregateOrchestratorActionsReportDeprioritizeSearchVectorUpdateFieldActions(
{
orchestratorActionsReport,
flatFieldMetadataMaps,
},
);
const updateFieldActions = result.fieldMetadata
.update as UpdateFieldAction[];
const actualEntityIds = updateFieldActions.map((action) => action.entityId);
expect(actualEntityIds).toEqual([
'regular-field-1',
'regular-field-2',
'search-vector-field-1',
]);
expect(result.fieldMetadata.create).toEqual(
orchestratorActionsReport.fieldMetadata.create,
);
expect(result.fieldMetadata.delete).toEqual(
orchestratorActionsReport.fieldMetadata.delete,
);
expect(result.objectMetadata).toEqual(
orchestratorActionsReport.objectMetadata,
);
});
});
@@ -0,0 +1,196 @@
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import { FieldMetadataType, type FromTo } from 'twenty-shared/types';
import { type MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity.type';
import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock';
import { flatEntityDeletedCreatedUpdatedMatrixDispatcher } from 'src/engine/workspace-manager/workspace-migration/utils/flat-entity-deleted-created-updated-matrix-dispatcher.util';
import { type WorkspaceMigrationBuilderOptions } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-builder-options.type';
type TestContext = FromTo<MetadataFlatEntity<'fieldMetadata'>[]> & {
metadataName: 'fieldMetadata';
buildOptions: WorkspaceMigrationBuilderOptions;
};
describe('flatEntityDeletedCreatedUpdatedMatrixDispatcher', () => {
const testCases = [
{
title: 'It should detect a created entity',
context: {
from: [],
to: [
getFlatFieldMetadataMock({
objectMetadataId: 'object-metadata-id-1',
type: FieldMetadataType.TEXT,
universalIdentifier: 'universal-identifier-1',
id: 'field-id-1',
workspaceId: 'workspace-id-1',
applicationId: 'application-id-1',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
}),
],
metadataName: 'fieldMetadata',
buildOptions: {
inferDeletionFromMissingEntities: { fieldMetadata: false },
isSystemBuild: false,
},
},
},
{
title: 'It should detect a deleted entity',
context: {
from: [
getFlatFieldMetadataMock({
objectMetadataId: 'object-metadata-id-1',
type: FieldMetadataType.TEXT,
universalIdentifier: 'universal-identifier-1',
id: 'field-id-1',
workspaceId: 'workspace-id-1',
applicationId: 'application-id-1',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
}),
],
to: [],
metadataName: 'fieldMetadata',
buildOptions: {
inferDeletionFromMissingEntities: { fieldMetadata: true },
isSystemBuild: false,
},
},
},
{
title: 'It should detect an updated entity',
context: {
from: [
getFlatFieldMetadataMock({
objectMetadataId: 'object-metadata-id-1',
type: FieldMetadataType.TEXT,
universalIdentifier: 'universal-identifier-1',
id: 'field-id-1',
workspaceId: 'workspace-id-1',
applicationId: 'application-id-1',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
isActive: false,
}),
],
to: [
getFlatFieldMetadataMock({
objectMetadataId: 'object-metadata-id-1',
type: FieldMetadataType.TEXT,
universalIdentifier: 'universal-identifier-1',
id: 'field-id-1',
workspaceId: 'workspace-id-1',
applicationId: 'application-id-1',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
isActive: true,
}),
],
metadataName: 'fieldMetadata',
buildOptions: {
inferDeletionFromMissingEntities: { fieldMetadata: false },
isSystemBuild: false,
},
},
},
{
title: 'It should detect created, deleted and updated entities',
context: {
from: [
getFlatFieldMetadataMock({
objectMetadataId: 'object-metadata-id-1',
type: FieldMetadataType.TEXT,
universalIdentifier: 'universal-identifier-1',
id: 'field-id-1',
workspaceId: 'workspace-id-1',
applicationId: 'application-id-1',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
isActive: true,
}),
getFlatFieldMetadataMock({
objectMetadataId: 'object-metadata-id-1',
type: FieldMetadataType.TEXT,
universalIdentifier: 'universal-identifier-2',
id: 'field-id-2',
workspaceId: 'workspace-id-1',
applicationId: 'application-id-1',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
}),
],
to: [
getFlatFieldMetadataMock({
objectMetadataId: 'object-metadata-id-1',
type: FieldMetadataType.TEXT,
universalIdentifier: 'universal-identifier-1',
id: 'field-id-1',
workspaceId: 'workspace-id-1',
applicationId: 'application-id-1',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
isActive: false,
}),
getFlatFieldMetadataMock({
objectMetadataId: 'object-metadata-id-1',
type: FieldMetadataType.TEXT,
universalIdentifier: 'universal-identifier-3',
id: 'field-id-3',
workspaceId: 'workspace-id-1',
applicationId: 'application-id-1',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
}),
],
metadataName: 'fieldMetadata',
buildOptions: {
inferDeletionFromMissingEntities: { fieldMetadata: true },
isSystemBuild: false,
},
},
},
{
title:
'It should not detect deleted entities when inferDeletionFromMissingEntities is false',
context: {
from: [
getFlatFieldMetadataMock({
objectMetadataId: 'object-metadata-id-1',
type: FieldMetadataType.TEXT,
universalIdentifier: 'universal-identifier-1',
id: 'field-id-1',
workspaceId: 'workspace-id-1',
applicationId: 'application-id-1',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
}),
],
to: [],
metadataName: 'fieldMetadata',
buildOptions: {
inferDeletionFromMissingEntities: { fieldMetadata: false },
isSystemBuild: false,
},
},
},
] as EachTestingContext<TestContext>[];
test.each(eachTestingContextFilter(testCases))(
'$title',
({ context: { from, to, metadataName, buildOptions } }) => {
const result = flatEntityDeletedCreatedUpdatedMatrixDispatcher({
from: from as any,
to: to as any,
metadataName,
buildOptions,
});
expect(result).toMatchSnapshot();
},
);
});
@@ -0,0 +1,245 @@
import { type EachTestingContext } from 'twenty-shared/testing';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type SyncableFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/flat-entity.type';
import {
type CircularDependencyValidationResult,
validateFlatEntityCircularDependency,
} from 'src/engine/workspace-manager/workspace-migration/utils/validate-flat-entity-circular-dependency.util';
type TestFlatEntity = SyncableFlatEntity & {
parentId: string | null;
};
const createFlatEntityMaps = (
entities: TestFlatEntity[],
): FlatEntityMaps<TestFlatEntity> => ({
byId: Object.fromEntries(entities.map((entity) => [entity.id, entity])),
idByUniversalIdentifier: Object.fromEntries(
entities.map((entity) => [entity.universalIdentifier, entity.id]),
),
universalIdentifiersByApplicationId: {},
});
const createTestEntity = (
id: string,
parentId: string | null = null,
): TestFlatEntity => ({
id,
parentId,
universalIdentifier: id,
applicationId: 'test-app',
workspaceId: 'test-workspace',
});
type TestContext = {
flatEntityId: string;
flatEntityParentId: string;
maxDepth?: number;
entities: TestFlatEntity[];
expected: CircularDependencyValidationResult;
};
type ValidateCircularDependencyTestCase = EachTestingContext<TestContext>;
const testCases: ValidateCircularDependencyTestCase[] = [
{
title: 'should fail with self_reference when entity is its own parent',
context: {
flatEntityId: 'entity-1',
flatEntityParentId: 'entity-1',
entities: [],
expected: {
status: 'fail',
reason: 'self_reference',
},
},
},
{
title: 'should succeed with depth 2 when parent has no parent',
context: {
flatEntityId: 'entity-child',
flatEntityParentId: 'entity-parent',
entities: [createTestEntity('entity-parent', null)],
expected: {
status: 'success',
depth: 2,
},
},
},
{
title: 'should succeed with depth 3 when parent has a grandparent',
context: {
flatEntityId: 'entity-child',
flatEntityParentId: 'entity-parent',
entities: [
createTestEntity('entity-grandparent', null),
createTestEntity('entity-parent', 'entity-grandparent'),
],
expected: {
status: 'success',
depth: 3,
},
},
},
{
title:
'should fail with circular_dependency when parent points back to entity',
context: {
flatEntityId: 'entity-a',
flatEntityParentId: 'entity-b',
entities: [createTestEntity('entity-b', 'entity-a')],
expected: {
status: 'fail',
reason: 'circular_dependency',
depth: 2,
},
},
},
{
title:
'should fail with circular_dependency when chain creates a cycle (A -> B -> C -> A)',
context: {
flatEntityId: 'entity-a',
flatEntityParentId: 'entity-b',
entities: [
createTestEntity('entity-b', 'entity-c'),
createTestEntity('entity-c', 'entity-a'),
],
expected: {
status: 'fail',
reason: 'circular_dependency',
depth: 3,
},
},
},
{
title:
'should fail with circular_dependency when ancestors form a cycle among themselves',
context: {
flatEntityId: 'entity-new',
flatEntityParentId: 'entity-a',
entities: [
createTestEntity('entity-a', 'entity-b'),
createTestEntity('entity-b', 'entity-a'),
],
expected: {
status: 'fail',
reason: 'circular_dependency',
depth: 3,
},
},
},
{
title: 'should succeed when parent does not exist in maps',
context: {
flatEntityId: 'entity-child',
flatEntityParentId: 'non-existent-parent',
entities: [],
expected: {
status: 'success',
depth: 1,
},
},
},
{
title: 'should fail with max_depth_exceeded when depth exceeds maxDepth',
context: {
flatEntityId: 'entity-child',
flatEntityParentId: 'entity-parent',
maxDepth: 2,
entities: [
createTestEntity('entity-grandparent', null),
createTestEntity('entity-parent', 'entity-grandparent'),
],
expected: {
status: 'fail',
reason: 'max_depth_exceeded',
depth: 3,
},
},
},
{
title: 'should succeed when depth equals maxDepth',
context: {
flatEntityId: 'entity-child',
flatEntityParentId: 'entity-parent',
maxDepth: 3,
entities: [
createTestEntity('entity-grandparent', null),
createTestEntity('entity-parent', 'entity-grandparent'),
],
expected: {
status: 'success',
depth: 3,
},
},
},
{
title: 'should succeed with depth 2 when maxDepth is 2 and parent is root',
context: {
flatEntityId: 'entity-child',
flatEntityParentId: 'entity-parent',
maxDepth: 2,
entities: [createTestEntity('entity-parent', null)],
expected: {
status: 'success',
depth: 2,
},
},
},
{
title:
'should check max depth before circular dependency to prevent infinite loop',
context: {
flatEntityId: 'entity-new',
flatEntityParentId: 'entity-a',
maxDepth: 2,
entities: [
createTestEntity('entity-a', 'entity-b'),
createTestEntity('entity-b', 'entity-c'),
createTestEntity('entity-c', 'entity-d'),
createTestEntity('entity-d', 'entity-a'),
],
expected: {
status: 'fail',
reason: 'max_depth_exceeded',
depth: 3,
},
},
},
{
title: 'should handle deep valid hierarchy without maxDepth',
context: {
flatEntityId: 'entity-new',
flatEntityParentId: 'entity-1',
entities: [
createTestEntity('entity-5', null),
createTestEntity('entity-4', 'entity-5'),
createTestEntity('entity-3', 'entity-4'),
createTestEntity('entity-2', 'entity-3'),
createTestEntity('entity-1', 'entity-2'),
],
expected: {
status: 'success',
depth: 6,
},
},
},
];
describe('validateFlatEntityCircularDependency', () => {
test.each(testCases)('$title', ({ context }) => {
const flatEntityMaps = createFlatEntityMaps(context.entities);
const result = validateFlatEntityCircularDependency({
flatEntityId: context.flatEntityId,
flatEntityParentId: context.flatEntityParentId,
maxDepth: context.maxDepth,
parentIdKey: 'parentId',
flatEntityMaps,
});
expect(result).toEqual(context.expected);
});
});
@@ -0,0 +1,54 @@
import { isDefined } from 'twenty-shared/utils';
import {
FlatEntityMapsException,
FlatEntityMapsExceptionCode,
} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type SyncableFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/flat-entity.type';
type AddFlatEntityToFlatEntityMapsThroughMutationOrThrowArgs<
T extends SyncableFlatEntity,
> = {
flatEntity: T;
flatEntityMapsToMutate: FlatEntityMaps<T>;
};
export const addFlatEntityToFlatEntityMapsThroughMutationOrThrow = <
T extends SyncableFlatEntity,
>({
flatEntity,
flatEntityMapsToMutate,
}: AddFlatEntityToFlatEntityMapsThroughMutationOrThrowArgs<T>): void => {
if (isDefined(flatEntityMapsToMutate.byId[flatEntity.id])) {
throw new FlatEntityMapsException(
'addFlatEntityToFlatEntityMapsThroughMutationOrThrow: flat entity to add already exists',
FlatEntityMapsExceptionCode.ENTITY_ALREADY_EXISTS,
);
}
flatEntityMapsToMutate.byId[flatEntity.id] = flatEntity;
flatEntityMapsToMutate.idByUniversalIdentifier[
flatEntity.universalIdentifier
] = flatEntity.id;
if (isDefined(flatEntity.applicationId)) {
const existingUniversalIdentifiers =
flatEntityMapsToMutate.universalIdentifiersByApplicationId[
flatEntity.applicationId
];
if (isDefined(existingUniversalIdentifiers)) {
if (
!existingUniversalIdentifiers.includes(flatEntity.universalIdentifier)
) {
existingUniversalIdentifiers.push(flatEntity.universalIdentifier);
}
} else {
flatEntityMapsToMutate.universalIdentifiersByApplicationId[
flatEntity.applicationId
] = [flatEntity.universalIdentifier];
}
}
};
@@ -0,0 +1,189 @@
import { isDefined } from 'twenty-shared/utils';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { type AggregateOrchestratorActionsReportArgs } from 'src/engine/workspace-manager/workspace-migration/types/workspace-migration-aggregate-orchestrator-actions-report-args.type';
import { type OrchestratorActionsReport } from 'src/engine/workspace-manager/workspace-migration/types/workspace-migration-orchestrator.type';
import { type CreateFieldAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/types/workspace-migration-field-action';
import { type CreateObjectAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/object/types/workspace-migration-object-action';
type AggregatedActions = {
createdFieldActionByObjectMetadataId: Record<string, CreateFieldAction>;
createdObjectActionByObjectMetadataId: Record<string, CreateObjectAction>;
};
export const aggregateOrchestratorActionsReportCreateObjectAndCreateFieldActions =
({
orchestratorActionsReport,
}: AggregateOrchestratorActionsReportArgs): OrchestratorActionsReport => {
const initialCreatedObjectActionByObjectMetadataId = (
orchestratorActionsReport.objectMetadata.create as CreateObjectAction[]
).reduce(
(acc, createObjectAction) => ({
...acc,
[createObjectAction.flatEntity.id]: createObjectAction,
}),
{} as Record<string, CreateObjectAction>,
);
const initialAccumulator: AggregatedActions = {
createdFieldActionByObjectMetadataId: {},
createdObjectActionByObjectMetadataId:
initialCreatedObjectActionByObjectMetadataId,
};
const {
createdFieldActionByObjectMetadataId,
createdObjectActionByObjectMetadataId,
} = (
orchestratorActionsReport.fieldMetadata.create as CreateFieldAction[]
).reduce<AggregatedActions>(
(
{
createdObjectActionByObjectMetadataId,
createdFieldActionByObjectMetadataId,
},
createFieldAction,
) => {
const existingCreateObjectAction =
createdObjectActionByObjectMetadataId[
createFieldAction.objectMetadataId
];
const existingCreateFieldAction =
createdFieldActionByObjectMetadataId[
createFieldAction.objectMetadataId
];
const initialAccumulator: {
morphOrRelationFlatFieldMetadatas: FlatFieldMetadata[];
otherFlatFieldMetadatas: FlatFieldMetadata[];
} = {
morphOrRelationFlatFieldMetadatas: [],
otherFlatFieldMetadatas: [],
};
const { morphOrRelationFlatFieldMetadatas, otherFlatFieldMetadatas } =
createFieldAction.flatFieldMetadatas.reduce(
(acc, flatFieldMetadata) => {
if (isMorphOrRelationFlatFieldMetadata(flatFieldMetadata)) {
return {
...acc,
morphOrRelationFlatFieldMetadatas: [
...acc.morphOrRelationFlatFieldMetadatas,
flatFieldMetadata,
],
};
}
return {
...acc,
otherFlatFieldMetadatas: [
...acc.otherFlatFieldMetadatas,
flatFieldMetadata,
],
};
},
initialAccumulator,
);
if (isDefined(existingCreateObjectAction)) {
if (isDefined(existingCreateFieldAction)) {
return {
createdObjectActionByObjectMetadataId: {
...createdObjectActionByObjectMetadataId,
[createFieldAction.objectMetadataId]: {
...existingCreateObjectAction,
flatFieldMetadatas: [
...existingCreateObjectAction.flatFieldMetadatas,
...otherFlatFieldMetadatas,
],
},
},
createdFieldActionByObjectMetadataId: {
...createdFieldActionByObjectMetadataId,
[createFieldAction.objectMetadataId]: {
...existingCreateFieldAction,
flatFieldMetadatas: [
...existingCreateFieldAction.flatFieldMetadatas,
...morphOrRelationFlatFieldMetadatas,
],
},
},
};
}
if (morphOrRelationFlatFieldMetadatas.length > 0) {
return {
createdObjectActionByObjectMetadataId: {
...createdObjectActionByObjectMetadataId,
[createFieldAction.objectMetadataId]: {
...existingCreateObjectAction,
flatFieldMetadatas: [
...existingCreateObjectAction.flatFieldMetadatas,
...otherFlatFieldMetadatas,
],
},
},
createdFieldActionByObjectMetadataId: {
...createdFieldActionByObjectMetadataId,
[createFieldAction.objectMetadataId]: {
...createFieldAction,
flatFieldMetadatas: morphOrRelationFlatFieldMetadatas,
},
},
};
}
return {
createdObjectActionByObjectMetadataId: {
...createdObjectActionByObjectMetadataId,
[createFieldAction.objectMetadataId]: {
...existingCreateObjectAction,
flatFieldMetadatas: [
...existingCreateObjectAction.flatFieldMetadatas,
...otherFlatFieldMetadatas,
],
},
},
createdFieldActionByObjectMetadataId,
};
}
if (isDefined(existingCreateFieldAction)) {
return {
createdFieldActionByObjectMetadataId: {
...createdFieldActionByObjectMetadataId,
[createFieldAction.objectMetadataId]: {
...existingCreateFieldAction,
flatFieldMetadatas: [
...existingCreateFieldAction.flatFieldMetadatas,
...createFieldAction.flatFieldMetadatas,
],
},
},
createdObjectActionByObjectMetadataId,
};
}
return {
createdFieldActionByObjectMetadataId: {
...createdFieldActionByObjectMetadataId,
[createFieldAction.objectMetadataId]: createFieldAction,
},
createdObjectActionByObjectMetadataId,
};
},
initialAccumulator,
);
return {
...orchestratorActionsReport,
fieldMetadata: {
...orchestratorActionsReport.fieldMetadata,
create: Object.values(createdFieldActionByObjectMetadataId),
},
objectMetadata: {
...orchestratorActionsReport.objectMetadata,
create: Object.values(createdObjectActionByObjectMetadataId),
},
};
};
@@ -0,0 +1,60 @@
import { isDefined } from 'twenty-shared/utils';
import { type AggregateOrchestratorActionsReportArgs } from 'src/engine/workspace-manager/workspace-migration/types/workspace-migration-aggregate-orchestrator-actions-report-args.type';
import { type OrchestratorActionsReport } from 'src/engine/workspace-manager/workspace-migration/types/workspace-migration-orchestrator.type';
import { type DeleteFieldAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/types/workspace-migration-field-action';
import { type DeleteObjectAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/object/types/workspace-migration-object-action';
type AggregatedActions = {
deleteFieldActionByFieldMetadataId: Record<string, DeleteFieldAction>;
};
export const aggregateOrchestratorActionsReportDeleteObjectAndDeleteFieldActions =
({
orchestratorActionsReport,
}: AggregateOrchestratorActionsReportArgs): OrchestratorActionsReport => {
const deleteObjectActionByObjectMetadataId = (
orchestratorActionsReport.objectMetadata.delete as DeleteObjectAction[]
).reduce<Record<string, DeleteObjectAction>>(
(acc, deleteObjectAction) => ({
...acc,
[deleteObjectAction.entityId]: deleteObjectAction,
}),
{},
);
const initialAccumulator: AggregatedActions = {
deleteFieldActionByFieldMetadataId: {},
};
const { deleteFieldActionByFieldMetadataId } = (
orchestratorActionsReport.fieldMetadata.delete as DeleteFieldAction[]
).reduce<AggregatedActions>(
({ deleteFieldActionByFieldMetadataId }, deleteFieldAction) => {
const fieldParentObjectDeleteObjectAction =
deleteObjectActionByObjectMetadataId[
deleteFieldAction.objectMetadataId
];
if (isDefined(fieldParentObjectDeleteObjectAction)) {
return {
deleteFieldActionByFieldMetadataId,
};
}
return {
deleteFieldActionByFieldMetadataId: {
...deleteFieldActionByFieldMetadataId,
[deleteFieldAction.entityId]: deleteFieldAction,
},
};
},
initialAccumulator,
);
return {
...orchestratorActionsReport,
fieldMetadata: {
...orchestratorActionsReport.fieldMetadata,
delete: Object.values(deleteFieldActionByFieldMetadataId),
},
};
};
@@ -0,0 +1,64 @@
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/search-field-metadata/constants/search-vector-field.constants';
import { type AggregateOrchestratorActionsReportArgs } from 'src/engine/workspace-manager/workspace-migration/types/workspace-migration-aggregate-orchestrator-actions-report-args.type';
import { type OrchestratorActionsReport } from 'src/engine/workspace-manager/workspace-migration/types/workspace-migration-orchestrator.type';
import { type UpdateFieldAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/types/workspace-migration-field-action';
export const aggregateOrchestratorActionsReportDeprioritizeSearchVectorUpdateFieldActions =
({
orchestratorActionsReport,
flatFieldMetadataMaps,
}: AggregateOrchestratorActionsReportArgs): OrchestratorActionsReport => {
assertIsDefinedOrThrow(flatFieldMetadataMaps);
const updateFieldActions = orchestratorActionsReport.fieldMetadata
.update as UpdateFieldAction[];
const { searchVectorUpdateFieldActions, otherUpdateFieldActions } =
updateFieldActions.reduce<{
searchVectorUpdateFieldActions: UpdateFieldAction[];
otherUpdateFieldActions: UpdateFieldAction[];
}>(
(acc, updateFieldAction) => {
const flatFieldMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityMaps: flatFieldMetadataMaps,
flatEntityId: updateFieldAction.entityId,
});
const isSearchVectorUpdateFieldAction =
flatFieldMetadata?.name === SEARCH_VECTOR_FIELD.name;
if (isSearchVectorUpdateFieldAction) {
return {
...acc,
searchVectorUpdateFieldActions: [
...acc.searchVectorUpdateFieldActions,
updateFieldAction,
],
};
}
return {
...acc,
otherUpdateFieldActions: [
...acc.otherUpdateFieldActions,
updateFieldAction,
],
};
},
{
searchVectorUpdateFieldActions: [],
otherUpdateFieldActions: [],
},
);
return {
...orchestratorActionsReport,
fieldMetadata: {
...orchestratorActionsReport.fieldMetadata,
update: [...otherUpdateFieldActions, ...searchVectorUpdateFieldActions],
},
};
};
@@ -0,0 +1,24 @@
import { type AggregateOrchestratorActionsReportArgs } from 'src/engine/workspace-manager/workspace-migration/types/workspace-migration-aggregate-orchestrator-actions-report-args.type';
import { aggregateOrchestratorActionsReportCreateObjectAndCreateFieldActions } from 'src/engine/workspace-manager/workspace-migration/utils/aggregate-orchestrator-actions-report-create-object-and-create-field-actions.util';
import { aggregateOrchestratorActionsReportDeleteObjectAndDeleteFieldActions } from 'src/engine/workspace-manager/workspace-migration/utils/aggregate-orchestrator-actions-report-delete-object-and-delete-field.util';
import { aggregateOrchestratorActionsReportDeprioritizeSearchVectorUpdateFieldActions } from 'src/engine/workspace-manager/workspace-migration/utils/aggregate-orchestrator-actions-report-deprioritize-search-vector-update-field-actions.util';
export const aggregateOrchestratorActionsReport = ({
orchestratorActionsReport,
flatFieldMetadataMaps,
}: AggregateOrchestratorActionsReportArgs) => {
const aggregatedOrchestratorActionsReport = [
aggregateOrchestratorActionsReportCreateObjectAndCreateFieldActions,
aggregateOrchestratorActionsReportDeleteObjectAndDeleteFieldActions,
aggregateOrchestratorActionsReportDeprioritizeSearchVectorUpdateFieldActions,
].reduce(
(currentOrchestratorActionsReport, aggregator) =>
aggregator({
orchestratorActionsReport: currentOrchestratorActionsReport,
flatFieldMetadataMaps,
}),
orchestratorActionsReport,
);
return { aggregatedOrchestratorActionsReport };
};
@@ -0,0 +1,13 @@
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
type ComputePostgresEnumNameParams = {
tableName: string;
columnName: string;
};
export const computePostgresEnumName = ({
tableName,
columnName,
}: ComputePostgresEnumNameParams): string => {
return removeSqlDDLInjection(`${tableName}_${columnName}_enum`);
};
@@ -0,0 +1,22 @@
import { type RelationOnDeleteAction } from 'twenty-shared/types';
export const convertOnDeleteActionToOnDelete = (
onDeleteAction: RelationOnDeleteAction | undefined,
): 'CASCADE' | 'SET NULL' | 'RESTRICT' | 'NO ACTION' | undefined => {
if (!onDeleteAction) {
return undefined;
}
switch (onDeleteAction) {
case 'CASCADE':
return 'CASCADE';
case 'SET_NULL':
return 'SET NULL';
case 'RESTRICT':
return 'RESTRICT';
case 'NO_ACTION':
return 'NO ACTION';
default:
throw new Error('Invalid onDeleteAction');
}
};
@@ -0,0 +1,60 @@
import { isDefined } from 'twenty-shared/utils';
import {
FlatEntityMapsException,
FlatEntityMapsExceptionCode,
} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type SyncableFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/flat-entity.type';
export type DeleteFlatEntityFromFlatEntityMapsThroughMutationOrThrowArgs<
T extends SyncableFlatEntity,
> = {
entityToDeleteId: string;
flatEntityMapsToMutate: FlatEntityMaps<T>;
};
export const deleteFlatEntityFromFlatEntityMapsThroughMutationOrThrow = <
T extends SyncableFlatEntity,
>({
flatEntityMapsToMutate,
entityToDeleteId,
}: DeleteFlatEntityFromFlatEntityMapsThroughMutationOrThrowArgs<T>): void => {
const entityToDelete = flatEntityMapsToMutate.byId[entityToDeleteId];
if (!isDefined(entityToDelete)) {
throw new FlatEntityMapsException(
'deleteFlatEntityFromFlatEntityMapsThroughMutationOrThrow: entity to delete not found',
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
);
}
delete flatEntityMapsToMutate.byId[entityToDeleteId];
delete flatEntityMapsToMutate.idByUniversalIdentifier[
entityToDelete.universalIdentifier
];
if (isDefined(entityToDelete.applicationId)) {
const universalIdentifiers =
flatEntityMapsToMutate.universalIdentifiersByApplicationId[
entityToDelete.applicationId
];
if (isDefined(universalIdentifiers)) {
const index = universalIdentifiers.indexOf(
entityToDelete.universalIdentifier,
);
if (index !== -1) {
universalIdentifiers.splice(index, 1);
}
if (universalIdentifiers.length === 0) {
delete flatEntityMapsToMutate.universalIdentifiersByApplicationId[
entityToDelete.applicationId
];
}
}
}
};
@@ -0,0 +1,24 @@
import { type AllMetadataName } from 'twenty-shared/metadata';
import { type FlatEntityPropertiesToCompare } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-properties-to-compare.type';
import { type FlatEntityPropertiesUpdates } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-properties-updates.type';
import { type FlatEntityPropertyUpdate } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-property-update.type';
import { type MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity.type';
import { isPropertyUpdate } from 'src/engine/workspace-manager/workspace-migration/utils/is-property-update.util';
export const findFlatEntityPropertyUpdate = <
T extends AllMetadataName,
P extends Extract<
FlatEntityPropertiesToCompare<T>,
keyof MetadataFlatEntity<T>
>,
>({
property,
flatEntityUpdates,
}: {
flatEntityUpdates: FlatEntityPropertiesUpdates<T>;
property: P;
}) =>
flatEntityUpdates.find((update): update is FlatEntityPropertyUpdate<T, P> =>
isPropertyUpdate(update, property),
);
@@ -0,0 +1,112 @@
import { type AllMetadataName } from 'twenty-shared/metadata';
import { type FromTo } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ALL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY } from 'src/engine/metadata-modules/flat-entity/constant/all-flat-entity-properties-to-compare-and-stringify.constant';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { type FlatEntityPropertiesToCompare } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-properties-to-compare.type';
import { type FlatEntityPropertiesToStringify } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-properties-to-stringify.type';
import { type FlatEntityPropertiesUpdates } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-properties-updates.type';
import { type MetadataFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity-maps.type';
import { type MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity.type';
import { compareTwoFlatEntity } from 'src/engine/metadata-modules/flat-entity/utils/compare-two-flat-entity.util';
import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/add-flat-entity-to-flat-entity-maps-through-mutation-or-throw.util';
import { shouldInferDeletionFromMissingEntities } from 'src/engine/workspace-manager/workspace-migration/utils/should-infer-deletion-from-missing-entities.util';
import { type WorkspaceMigrationBuilderOptions } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-builder-options.type';
export type DeletedCreatedUpdatedMatrix<T extends AllMetadataName> = {
createdFlatEntityMaps: MetadataFlatEntityMaps<T>;
deletedFlatEntityMaps: MetadataFlatEntityMaps<T>;
updatedFlatEntityMaps: {
byId: Record<
string,
{
updates: FlatEntityPropertiesUpdates<T>;
}
>;
};
};
export type UniversalIdentifierItem = {
universalIdentifier: string;
};
type FlatEntityDeletedCreatedUpdatedMatrixDispatcherArgs<
T extends AllMetadataName,
> = FromTo<MetadataFlatEntity<T>[]> & {
metadataName: T;
buildOptions: WorkspaceMigrationBuilderOptions;
};
export const flatEntityDeletedCreatedUpdatedMatrixDispatcher = <
T extends AllMetadataName,
>({
from,
to,
metadataName,
buildOptions,
}: FlatEntityDeletedCreatedUpdatedMatrixDispatcherArgs<T>): DeletedCreatedUpdatedMatrix<T> => {
const initialDispatcher: DeletedCreatedUpdatedMatrix<T> = {
createdFlatEntityMaps: createEmptyFlatEntityMaps(),
deletedFlatEntityMaps: createEmptyFlatEntityMaps(),
updatedFlatEntityMaps: { byId: {} },
};
const fromMap = new Map(from.map((obj) => [obj.universalIdentifier, obj]));
const toMap = new Map(to.map((obj) => [obj.universalIdentifier, obj]));
if (shouldInferDeletionFromMissingEntities({ buildOptions, metadataName })) {
for (const [universalIdentifier, fromEntity] of fromMap) {
if (toMap.has(universalIdentifier)) {
continue;
}
addFlatEntityToFlatEntityMapsThroughMutationOrThrow({
flatEntity: fromEntity,
flatEntityMapsToMutate: initialDispatcher.deletedFlatEntityMaps,
});
}
}
for (const [universalIdentifier, toFlatEntity] of toMap) {
if (fromMap.has(universalIdentifier)) {
continue;
}
addFlatEntityToFlatEntityMapsThroughMutationOrThrow({
flatEntity: toFlatEntity,
flatEntityMapsToMutate: initialDispatcher.createdFlatEntityMaps,
});
}
const { propertiesToCompare, propertiesToStringify } =
ALL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY[metadataName];
for (const [universalIdentifier, fromFlatEntity] of fromMap) {
const toFlatEntity = toMap.get(universalIdentifier);
if (!isDefined(toFlatEntity)) {
continue;
}
const updates = compareTwoFlatEntity({
fromFlatEntity,
toFlatEntity,
propertiesToCompare: propertiesToCompare as unknown as Extract<
FlatEntityPropertiesToCompare<T>,
keyof MetadataFlatEntity<T>
>[],
propertiesToStringify: propertiesToStringify as unknown as Extract<
FlatEntityPropertiesToStringify<T>,
keyof MetadataFlatEntity<T>
>[],
});
if (updates.length === 0) {
continue;
}
initialDispatcher.updatedFlatEntityMaps.byId[toFlatEntity.id] = {
updates,
};
}
return initialDispatcher;
};
@@ -0,0 +1,14 @@
import { type AllMetadataName } from 'twenty-shared/metadata';
import { type MetadataWorkspaceMigrationActionsRecord } from 'src/engine/metadata-modules/flat-entity/types/metadata-workspace-migration-action.type';
export const getMetadataEmptyWorkspaceMigrationActionRecord = <
T extends AllMetadataName,
>(
_metadataName: T,
) =>
({
create: [],
delete: [],
update: [],
}) as MetadataWorkspaceMigrationActionsRecord<T>;
@@ -0,0 +1,8 @@
import { type PropertyUpdate } from 'src/engine/workspace-manager/workspace-migration/types/property-update.type';
export const isPropertyUpdate = <T, P extends keyof T>(
update: PropertyUpdate<T, keyof T>,
property: P,
): update is PropertyUpdate<T, P> => {
return update.property === property;
};
@@ -0,0 +1,3 @@
export const removeSqlDDLInjection = (value: string): string => {
return value.replace(/[^a-zA-Z0-9_]/g, '');
};
@@ -0,0 +1,28 @@
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type SyncableFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/flat-entity.type';
import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/add-flat-entity-to-flat-entity-maps-through-mutation-or-throw.util';
import { deleteFlatEntityFromFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/delete-flat-entity-from-flat-entity-maps-through-mutation-or-throw.util';
export type ReplaceFlatEntityInFlatEntityMapsThroughMutationOrThrowArgs<
T extends SyncableFlatEntity,
> = {
flatEntity: T;
flatEntityMapsToMutate: FlatEntityMaps<T>;
};
export const replaceFlatEntityInFlatEntityMapsThroughMutationOrThrow = <
T extends SyncableFlatEntity,
>({
flatEntity,
flatEntityMapsToMutate,
}: ReplaceFlatEntityInFlatEntityMapsThroughMutationOrThrowArgs<T>): void => {
deleteFlatEntityFromFlatEntityMapsThroughMutationOrThrow({
flatEntityMapsToMutate,
entityToDeleteId: flatEntity.id,
});
addFlatEntityToFlatEntityMapsThroughMutationOrThrow({
flatEntity,
flatEntityMapsToMutate,
});
};
@@ -0,0 +1,16 @@
import { type AllMetadataName } from 'twenty-shared/metadata';
import { type WorkspaceMigrationBuilderOptions } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-builder-options.type';
export const shouldInferDeletionFromMissingEntities = ({
buildOptions,
metadataName,
}: {
buildOptions: WorkspaceMigrationBuilderOptions;
metadataName: AllMetadataName;
}): boolean => {
return (
buildOptions.inferDeletionFromMissingEntities === true ||
buildOptions.inferDeletionFromMissingEntities?.[metadataName] === true
);
};
@@ -0,0 +1,107 @@
import { isDefined } from 'twenty-shared/utils';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type SyncableFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/flat-entity.type';
export type CircularDependencyValidationSuccess = {
status: 'success';
depth: number;
};
export type CircularDependencyValidationFailure = {
status: 'fail';
reason: 'self_reference' | 'circular_dependency' | 'max_depth_exceeded';
depth?: number;
};
export type CircularDependencyValidationResult =
| CircularDependencyValidationSuccess
| CircularDependencyValidationFailure;
export const validateFlatEntityCircularDependency = <
T extends SyncableFlatEntity,
K extends keyof T,
>({
flatEntityId,
flatEntityParentId,
maxDepth,
parentIdKey,
flatEntityMaps,
}: {
flatEntityId: string;
flatEntityParentId: string;
maxDepth?: number;
parentIdKey: K;
flatEntityMaps: FlatEntityMaps<T>;
}): CircularDependencyValidationResult => {
// Direct self-reference check
if (flatEntityId === flatEntityParentId) {
return {
status: 'fail',
reason: 'self_reference',
};
}
// Traverse ancestor chain to detect cycles and measure depth
const visited = new Set<string>();
let currentParentId: string | null | undefined = flatEntityParentId;
let depth = 1;
while (isDefined(currentParentId)) {
if (isDefined(maxDepth) && depth > maxDepth) {
return {
status: 'fail',
reason: 'max_depth_exceeded',
depth,
};
}
// Check for circular dependency
if (currentParentId === flatEntityId) {
return {
status: 'fail',
reason: 'circular_dependency',
depth,
};
}
// Check for cycle in ancestors (already visited node)
if (visited.has(currentParentId)) {
return {
status: 'fail',
reason: 'circular_dependency',
depth,
};
}
visited.add(currentParentId);
const parentEntity: T | undefined = flatEntityMaps.byId[currentParentId];
if (!isDefined(parentEntity)) {
break;
}
const nextParentId: T[K] = parentEntity[parentIdKey];
currentParentId =
isDefined(nextParentId) && typeof nextParentId === 'string'
? nextParentId
: undefined;
depth++;
}
// Check max depth for the final depth value
if (isDefined(maxDepth) && depth > maxDepth) {
return {
status: 'fail',
reason: 'max_depth_exceeded',
depth,
};
}
return {
status: 'success',
depth,
};
};