diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/__tests__/topologically-sort-universal-flat-entities-for-self-referential-fks.util.spec.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/__tests__/topologically-sort-universal-flat-entities-for-self-referential-fks.util.spec.ts new file mode 100644 index 0000000000..2e0ed32b96 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/__tests__/topologically-sort-universal-flat-entities-for-self-referential-fks.util.spec.ts @@ -0,0 +1,208 @@ +import { v4 as uuidv4 } from 'uuid'; + +import { type UniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-maps.type'; +import { topologicallySortUniversalFlatEntitiesForSelfReferentialFks } from 'src/engine/workspace-manager/workspace-migration/utils/topologically-sort-universal-flat-entities-for-self-referential-fks.util'; + +const APPLICATION_UNIVERSAL_IDENTIFIER = uuidv4(); + +type TestNavigationMenuItemEntity = { + universalIdentifier: string; + applicationUniversalIdentifier: string; + folderUniversalIdentifier: string | null; +}; + +const createEntity = ( + universalIdentifier: string, + folderUniversalIdentifier: string | null = null, +): TestNavigationMenuItemEntity => ({ + universalIdentifier, + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + folderUniversalIdentifier, +}); + +const buildMaps = ( + entities: TestNavigationMenuItemEntity[], +): UniversalFlatEntityMaps => ({ + byUniversalIdentifier: Object.fromEntries( + entities.map((entity) => [entity.universalIdentifier, entity]), + ), +}); + +describe('topologicallySortUniversalFlatEntitiesForSelfReferentialFks', () => { + it('returns empty array for empty maps', () => { + const result = topologicallySortUniversalFlatEntitiesForSelfReferentialFks({ + metadataName: 'navigationMenuItem', + universalFlatEntityMaps: buildMaps([]) as never, + }); + + expect(result).toEqual([]); + }); + + it('returns original order for entities without self-referential FKs', () => { + const idA = uuidv4(); + const idB = uuidv4(); + + const maps = { + byUniversalIdentifier: { + [idA]: { + universalIdentifier: idA, + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + }, + [idB]: { + universalIdentifier: idB, + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + }, + }, + }; + + const result = topologicallySortUniversalFlatEntitiesForSelfReferentialFks({ + metadataName: 'objectMetadata', + universalFlatEntityMaps: maps as never, + }); + + expect(result).toEqual([idA, idB]); + }); + + it('returns original order when no entity references another in the batch', () => { + const itemA = uuidv4(); + const itemB = uuidv4(); + + const result = topologicallySortUniversalFlatEntitiesForSelfReferentialFks({ + metadataName: 'navigationMenuItem', + universalFlatEntityMaps: buildMaps([ + createEntity(itemA), + createEntity(itemB), + ]) as never, + }); + + expect(result).toEqual([itemA, itemB]); + }); + + it('sorts parent before child when child is listed first', () => { + const parentId = uuidv4(); + const childId = uuidv4(); + + const result = topologicallySortUniversalFlatEntitiesForSelfReferentialFks({ + metadataName: 'navigationMenuItem', + universalFlatEntityMaps: buildMaps([ + createEntity(childId, parentId), + createEntity(parentId), + ]) as never, + }); + + expect(result).toEqual([parentId, childId]); + }); + + it('keeps parent before child when already in correct order', () => { + const parentId = uuidv4(); + const childId = uuidv4(); + + const result = topologicallySortUniversalFlatEntitiesForSelfReferentialFks({ + metadataName: 'navigationMenuItem', + universalFlatEntityMaps: buildMaps([ + createEntity(parentId), + createEntity(childId, parentId), + ]) as never, + }); + + expect(result).toEqual([parentId, childId]); + }); + + it('sorts multi-level hierarchy: grandparent -> parent -> child', () => { + const grandparentId = uuidv4(); + const parentId = uuidv4(); + const childId = uuidv4(); + + const result = topologicallySortUniversalFlatEntitiesForSelfReferentialFks({ + metadataName: 'navigationMenuItem', + universalFlatEntityMaps: buildMaps([ + createEntity(childId, parentId), + createEntity(grandparentId), + createEntity(parentId, grandparentId), + ]) as never, + }); + + expect(result).toEqual([grandparentId, parentId, childId]); + }); + + it('handles multiple roots with children', () => { + const rootA = uuidv4(); + const rootB = uuidv4(); + const childA = uuidv4(); + const childB = uuidv4(); + + const result = topologicallySortUniversalFlatEntitiesForSelfReferentialFks({ + metadataName: 'navigationMenuItem', + universalFlatEntityMaps: buildMaps([ + createEntity(childB, rootB), + createEntity(childA, rootA), + createEntity(rootA), + createEntity(rootB), + ]) as never, + }); + + expect(result.indexOf(rootA)).toBeLessThan(result.indexOf(childA)); + expect(result.indexOf(rootB)).toBeLessThan(result.indexOf(childB)); + expect(result).toHaveLength(4); + }); + + it('ignores references to entities outside the batch', () => { + const externalParentId = uuidv4(); + const itemA = uuidv4(); + const itemB = uuidv4(); + + const result = topologicallySortUniversalFlatEntitiesForSelfReferentialFks({ + metadataName: 'navigationMenuItem', + universalFlatEntityMaps: buildMaps([ + createEntity(itemA, externalParentId), + createEntity(itemB), + ]) as never, + }); + + expect(result).toEqual([itemA, itemB]); + }); + + it('throws on cycles for entities without expected cycles', () => { + const idA = uuidv4(); + const idB = uuidv4(); + + expect(() => + topologicallySortUniversalFlatEntitiesForSelfReferentialFks({ + metadataName: 'navigationMenuItem', + universalFlatEntityMaps: buildMaps([ + createEntity(idA, idB), + createEntity(idB, idA), + ]) as never, + }), + ).toThrow(/Cyclic self-referential foreign key detected/); + }); + + it('appends cyclic entities for fieldMetadata (expected bidirectional cycles)', () => { + const idA = uuidv4(); + const idB = uuidv4(); + + const maps = { + byUniversalIdentifier: { + [idA]: { + universalIdentifier: idA, + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: idB, + }, + [idB]: { + universalIdentifier: idB, + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: idA, + }, + }, + }; + + const result = topologicallySortUniversalFlatEntitiesForSelfReferentialFks({ + metadataName: 'fieldMetadata', + universalFlatEntityMaps: maps as never, + }); + + expect(result).toHaveLength(2); + expect(result).toContain(idA); + expect(result).toContain(idB); + }); +}); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/topologically-sort-universal-flat-entities-for-self-referential-fks.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/topologically-sort-universal-flat-entities-for-self-referential-fks.util.ts new file mode 100644 index 0000000000..09cf884ee6 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/topologically-sort-universal-flat-entities-for-self-referential-fks.util.ts @@ -0,0 +1,139 @@ +import { type AllMetadataName } from 'twenty-shared/metadata'; +import { isDefined } from 'twenty-shared/utils'; + +import { ALL_MANY_TO_ONE_METADATA_RELATIONS } from 'src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant'; +import { type MetadataUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/metadata-universal-flat-entity-maps.type'; + +// fieldMetadata has bidirectional self-references (relation field A -> B and +// B -> A) that are handled by a separate pre-allocation code path in the +// runner. Cycles are expected and safe to append unsorted here. +const METADATA_NAMES_WITH_EXPECTED_CYCLES = new Set([ + 'fieldMetadata', +]); + +const getSelfReferentialUniversalForeignKeys = ( + metadataName: AllMetadataName, +): string[] => + Object.values(ALL_MANY_TO_ONE_METADATA_RELATIONS[metadataName]) + .filter( + ( + relation, + ): relation is { + metadataName: AllMetadataName; + universalForeignKey: string; + } => isDefined(relation) && relation.metadataName === metadataName, + ) + .map((relation) => relation.universalForeignKey); + +const getParentUniversalIdentifier = ({ + entity, + selfReferentialUniversalForeignKeys, +}: { + entity: Record; + selfReferentialUniversalForeignKeys: string[]; +}): string | null => + selfReferentialUniversalForeignKeys.reduce( + (found, universalForeignKey) => + found ?? + (entity[universalForeignKey] as string | null | undefined) ?? + null, + null, + ); + +// Sorts universal identifiers so that parents come before children +// for entities with self-referential FKs (e.g. navigationMenuItem.folder, +// viewFilterGroup.parentViewFilterGroup). Returns original order unchanged +// for entities without self-referential FKs. +export const topologicallySortUniversalFlatEntitiesForSelfReferentialFks = < + T extends AllMetadataName, +>({ + metadataName, + universalFlatEntityMaps, +}: { + metadataName: T; + universalFlatEntityMaps: MetadataUniversalFlatEntityMaps; +}): string[] => { + const selfReferentialUniversalForeignKeys = + getSelfReferentialUniversalForeignKeys(metadataName); + + const allUniversalIdentifiers = Object.keys( + universalFlatEntityMaps.byUniversalIdentifier, + ); + + if (selfReferentialUniversalForeignKeys.length === 0) { + return allUniversalIdentifiers; + } + + const universalIdentifierSet = new Set(allUniversalIdentifiers); + + const childrenByParent = new Map(); + const inDegree = new Map( + allUniversalIdentifiers.map((id) => [id, 0]), + ); + + for (const universalIdentifier of allUniversalIdentifiers) { + const entity = universalFlatEntityMaps.byUniversalIdentifier[ + universalIdentifier + ] as Record | undefined; + + if (!isDefined(entity)) { + continue; + } + + const parentId = getParentUniversalIdentifier({ + entity, + selfReferentialUniversalForeignKeys, + }); + + if (isDefined(parentId) && universalIdentifierSet.has(parentId)) { + childrenByParent.set(parentId, [ + ...(childrenByParent.get(parentId) ?? []), + universalIdentifier, + ]); + inDegree.set( + universalIdentifier, + (inDegree.get(universalIdentifier) ?? 0) + 1, + ); + } + } + + const roots = allUniversalIdentifiers.filter( + (id) => (inDegree.get(id) ?? 0) === 0, + ); + + const sorted = roots.reduce((accumulator, root) => { + accumulator.push(root); + + for (let i = accumulator.length - 1; i < accumulator.length; i++) { + const children = childrenByParent.get(accumulator[i]) ?? []; + + accumulator.push(...children); + } + + return accumulator; + }, []); + + if (sorted.length < allUniversalIdentifiers.length) { + if (!METADATA_NAMES_WITH_EXPECTED_CYCLES.has(metadataName)) { + throw new Error( + `Cyclic self-referential foreign key detected for ${metadataName}: ` + + `expected ${allUniversalIdentifiers.length} entities but sorted ${sorted.length}. ` + + `This entity does not use deferrable FKs, so cycles are not supported.`, + ); + } + + // Bidirectional self-references (e.g. fieldMetadata relation pairs where + // A -> B and B -> A) create cycles that cannot be topologically sorted. + // These rely on DEFERRABLE INITIALLY DEFERRED FKs at the DB level, + // so we append them at the end in their original order. + const sortedSet = new Set(sorted); + + for (const universalIdentifier of allUniversalIdentifiers) { + if (!sortedSet.has(universalIdentifier)) { + sorted.push(universalIdentifier); + } + } + } + + return sorted; +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service.ts index b8e0fd3fb7..e3321e6a08 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service.ts @@ -27,6 +27,7 @@ import { resetUniversalFlatEntityForeignKeyAggregators } from 'src/engine/worksp import { flatEntityDeletedCreatedUpdatedMatrixDispatcher } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/universal-flat-entity-deleted-created-updated-matrix-dispatcher.util'; import { getMetadataEmptyWorkspaceMigrationActionRecord } from 'src/engine/workspace-manager/workspace-migration/utils/get-metadata-empty-workspace-migration-action-record.util'; import { shouldInferDeletionFromMissingEntities } from 'src/engine/workspace-manager/workspace-migration/utils/should-infer-deletion-from-missing-entities.util'; +import { topologicallySortUniversalFlatEntitiesForSelfReferentialFks } from 'src/engine/workspace-manager/workspace-migration/utils/topologically-sort-universal-flat-entities-for-self-referential-fks.util'; import { FlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type'; import { FailedFlatEntityValidateAndBuild } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/failed-flat-entity-validate-and-build.type'; import { SuccessfulFlatEntityValidateAndBuild } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/successful-flat-entity-validate-and-build.type'; @@ -247,7 +248,14 @@ export abstract class WorkspaceEntityMigrationBuilderService< `EntityBuilder ${this.metadataName}`, 'creation validation', ); - for (const flatEntityToCreateUniversalIdentifier in createdFlatEntityMaps.byUniversalIdentifier) { + + const sortedCreateUniversalIdentifiers = + topologicallySortUniversalFlatEntitiesForSelfReferentialFks({ + metadataName: this.metadataName, + universalFlatEntityMaps: createdFlatEntityMaps, + }); + + for (const flatEntityToCreateUniversalIdentifier of sortedCreateUniversalIdentifiers) { const rawUniversalflatEntityToCreate = findFlatEntityByUniversalIdentifierOrThrow({ universalIdentifier: flatEntityToCreateUniversalIdentifier, diff --git a/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-navigation-menu-item.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-navigation-menu-item.integration-spec.ts new file mode 100644 index 0000000000..cbf6f6bd1f --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-navigation-menu-item.integration-spec.ts @@ -0,0 +1,251 @@ +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util'; +import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util'; +import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util'; +import { uninstallApplication } from 'test/integration/metadata/suites/application/utils/uninstall-application.util'; +import { findNavigationMenuItems } from 'test/integration/metadata/suites/navigation-menu-item/utils/find-navigation-menu-items.util'; +import { type Manifest } from 'twenty-shared/application'; +import { NavigationMenuItemType } from 'twenty-shared/types'; +import { v4 as uuidv4 } from 'uuid'; + +const TEST_APP_ID = uuidv4(); +const TEST_ROLE_ID = uuidv4(); +const TEST_FOLDER_ID = uuidv4(); +const TEST_CHILD_ID = uuidv4(); + +const NAV_ITEM_GQL_FIELDS = ` + id + type + name + icon + link + position + folderId + applicationId +`; + +let testApplicationId: string; + +const buildManifest = ( + overrides?: Partial>, +) => + buildBaseManifest({ + appId: TEST_APP_ID, + roleId: TEST_ROLE_ID, + overrides, + }); + +const findAppNavigationMenuItems = async () => { + const { data } = await findNavigationMenuItems({ + gqlFields: NAV_ITEM_GQL_FIELDS, + expectToFail: false, + input: undefined, + }); + + return data.navigationMenuItems.filter( + (item) => item.applicationId === testApplicationId, + ); +}; + +describe('Manifest update - navigation menu items', () => { + beforeEach(async () => { + await setupApplicationForSync({ + applicationUniversalIdentifier: TEST_APP_ID, + name: 'Test Application', + description: 'App for testing navigation menu item manifest updates', + sourcePath: 'test-manifest-update-nav', + }); + + const result = await globalThis.testDataSource.query( + `SELECT id FROM core."application" WHERE "universalIdentifier" = $1`, + [TEST_APP_ID], + ); + + testApplicationId = result[0].id; + }, 60000); + + afterEach(async () => { + try { + await uninstallApplication({ + universalIdentifier: TEST_APP_ID, + expectToFail: false, + }); + } catch { + // May fail if the test didn't fully install/sync + } + + await globalThis.testDataSource.query( + `DELETE FROM core."role" WHERE "universalIdentifier" = $1`, + [TEST_ROLE_ID], + ); + + await globalThis.testDataSource.query( + `DELETE FROM core."file" WHERE "applicationId" IN ( + SELECT id FROM core."application" WHERE "universalIdentifier" = $1 + )`, + [TEST_APP_ID], + ); + + await globalThis.testDataSource.query( + `DELETE FROM core."application" + WHERE "universalIdentifier" = $1`, + [TEST_APP_ID], + ); + + await globalThis.testDataSource.query( + `DELETE FROM core."applicationRegistration" + WHERE "universalIdentifier" = $1`, + [TEST_APP_ID], + ); + }); + + it('should create a folder and a child item when child is listed BEFORE folder in manifest', async () => { + await syncApplication({ + manifest: buildManifest({ + navigationMenuItems: [ + { + universalIdentifier: TEST_CHILD_ID, + type: NavigationMenuItemType.LINK, + name: 'Child Link', + icon: 'IconLink', + position: 1, + link: 'https://example.com', + folderUniversalIdentifier: TEST_FOLDER_ID, + }, + { + universalIdentifier: TEST_FOLDER_ID, + type: NavigationMenuItemType.FOLDER, + name: 'Test Folder', + icon: 'IconFolder', + position: 0, + }, + ], + }), + expectToFail: false, + }); + + const items = await findAppNavigationMenuItems(); + + expect(items).toHaveLength(2); + + const folder = items.find( + (item) => item.type === NavigationMenuItemType.FOLDER, + ); + const child = items.find( + (item) => item.type === NavigationMenuItemType.LINK, + ); + + expect(folder).toBeDefined(); + expect(folder).toMatchObject({ + type: NavigationMenuItemType.FOLDER, + name: 'Test Folder', + }); + + expect(child).toBeDefined(); + expect(child).toMatchObject({ + type: NavigationMenuItemType.LINK, + name: 'Child Link', + folderId: folder!.id, + }); + }, 60000); + + it('should update navigation menu item properties on second sync', async () => { + await syncApplication({ + manifest: buildManifest({ + navigationMenuItems: [ + { + universalIdentifier: TEST_FOLDER_ID, + type: NavigationMenuItemType.FOLDER, + name: 'Test Folder', + icon: 'IconFolder', + position: 0, + }, + ], + }), + expectToFail: false, + }); + + const itemsAfterFirstSync = await findAppNavigationMenuItems(); + + expect(itemsAfterFirstSync).toHaveLength(1); + expect(itemsAfterFirstSync[0]).toMatchObject({ + name: 'Test Folder', + icon: 'IconFolder', + }); + + await syncApplication({ + manifest: buildManifest({ + navigationMenuItems: [ + { + universalIdentifier: TEST_FOLDER_ID, + type: NavigationMenuItemType.FOLDER, + name: 'Renamed Folder', + icon: 'IconFolderOpen', + position: 0, + }, + ], + }), + expectToFail: false, + }); + + const itemsAfterSecondSync = await findAppNavigationMenuItems(); + + expect(itemsAfterSecondSync).toHaveLength(1); + expect(itemsAfterSecondSync[0]).toMatchObject({ + name: 'Renamed Folder', + icon: 'IconFolderOpen', + }); + }, 60000); + + it('should delete navigation menu items when removed from manifest on second sync', async () => { + await syncApplication({ + manifest: buildManifest({ + navigationMenuItems: [ + { + universalIdentifier: TEST_FOLDER_ID, + type: NavigationMenuItemType.FOLDER, + name: 'Test Folder', + icon: 'IconFolder', + position: 0, + }, + { + universalIdentifier: TEST_CHILD_ID, + type: NavigationMenuItemType.LINK, + name: 'Child Link', + icon: 'IconLink', + position: 1, + link: 'https://example.com', + folderUniversalIdentifier: TEST_FOLDER_ID, + }, + ], + }), + expectToFail: false, + }); + + const itemsAfterFirstSync = await findAppNavigationMenuItems(); + + expect(itemsAfterFirstSync).toHaveLength(2); + + await syncApplication({ + manifest: buildManifest({ + navigationMenuItems: [ + { + universalIdentifier: TEST_FOLDER_ID, + type: NavigationMenuItemType.FOLDER, + name: 'Test Folder', + icon: 'IconFolder', + position: 0, + }, + ], + }), + expectToFail: false, + }); + + const itemsAfterSecondSync = await findAppNavigationMenuItems(); + + expect(itemsAfterSecondSync).toHaveLength(1); + expect(itemsAfterSecondSync[0]).toMatchObject({ + type: NavigationMenuItemType.FOLDER, + name: 'Test Folder', + }); + }, 60000); +});