From f3aadbbb6679ef979fbedf23d65f41bf9aa40a57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Wed, 20 May 2026 09:21:52 +0200 Subject: [PATCH] chore(server): drop leftover favorite and favoriteFolder workspace objects (#20744) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds a 2.7.0 workspace upgrade command `upgrade:2-7:drop-favorite-objects` that removes the legacy `favorite` and `favoriteFolder` object metadata (and their workspace tables) from every active or suspended workspace. - The records were migrated to `navigationMenuItem` in the 1.17/1.18 upgrades and the entity code was deleted in #19536, but the per-workspace metadata rows were never cleaned up — so they still surface in the "Existing objects" settings list and expose stale CRUD tools to the AI/MCP layer (e.g. the model can hallucinate `create_favorite_folder` against a real-looking schema). ## Implementation notes - Modeled on [`upgrade:2-3:drop-message-direction-field`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/database/commands/upgrade-version-command/2-3/2-3-workspace-command-1777400000000-drop-message-direction-field.command.ts), but at object granularity. - Uses `ObjectMetadataService.deleteOneObject({ isSystemBuild: true })` so all cascading is handled by the existing pipeline: field metadata, indexes, relation fields on other workspace entities, command menu items, and the workspace data tables. Views and orphaned `navigationMenuItem` rows pointing at favorite views are removed by the existing `onDelete: 'CASCADE'` FKs. - Deletion order: `favorite` first (holds a relation to `favoriteFolder`), then `favoriteFolder`. - Both objects are flagged `isSystem: true`, hence `isSystemBuild: true` on the call. - Idempotent: workspaces where the object is already absent are logged and skipped. - Honors `--dry-run`. - Universal identifiers are hard-coded because the matching `STANDARD_OBJECTS` entries were deleted in #19536. ## Test plan - [ ] Run on a workspace that still has `favorite` / `favoriteFolder` in `core.objectMetadata` (verify in prod-like DB beforehand) and confirm both objects, their fields, indexes, relation fields on linked objects, views, and the workspace data tables are gone after running. - [ ] Re-run on the same workspace — confirm it logs "already absent" and exits clean (idempotency). - [ ] Run on a workspace where the objects don't exist (e.g. fresh local) — confirm clean no-op. - [ ] Run with \`--dry-run\` first — confirm log output and no DB mutations. - [ ] Confirm the "Existing objects" settings page no longer lists Favorites / Favorite Folders after the migration. ## Safety check before rollout Before running in prod, verify no workspace has live (non-soft-deleted) favorite data that didn't make it to \`navigationMenuItem\`: \`\`\`sql -- Per workspace SELECT count(*) FROM workspace_xxx.favorite WHERE "deletedAt" IS NULL; \`\`\` Should be ~0 in workspaces that ran the 1.17 / 1.18 migrations. --------- Co-authored-by: prastoin --- .../2-7/2-7-upgrade-version-command.module.ts | 8 +- ...000030000-drop-favorite-objects.command.ts | 105 ++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-7/2-7-workspace-command-1798000030000-drop-favorite-objects.command.ts diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-7/2-7-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-7/2-7-upgrade-version-command.module.ts index 4b1a89897c..bdb9e6d6ac 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-7/2-7-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-7/2-7-upgrade-version-command.module.ts @@ -1,18 +1,24 @@ import { Module } from '@nestjs/common'; import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module'; +import { DropFavoriteObjectsCommand } from 'src/database/commands/upgrade-version-command/2-7/2-7-workspace-command-1798000030000-drop-favorite-objects.command'; import { SyncCommandMenuItemAvailabilityExpressionsCommand } from 'src/database/commands/upgrade-version-command/2-7/2-7-workspace-command-1798000020000-sync-command-menu-item-availability-expressions.command'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module'; @Module({ imports: [ ApplicationModule, + ObjectMetadataModule, WorkspaceCacheModule, WorkspaceIteratorModule, WorkspaceMigrationModule, ], - providers: [SyncCommandMenuItemAvailabilityExpressionsCommand], + providers: [ + DropFavoriteObjectsCommand, + SyncCommandMenuItemAvailabilityExpressionsCommand, + ], }) export class V2_7_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-7/2-7-workspace-command-1798000030000-drop-favorite-objects.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-7/2-7-workspace-command-1798000030000-drop-favorite-objects.command.ts new file mode 100644 index 0000000000..fa5ef5c081 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-7/2-7-workspace-command-1798000030000-drop-favorite-objects.command.ts @@ -0,0 +1,105 @@ +import { Command } from 'nest-commander'; + +import { isDefined } from 'twenty-shared/utils'; + +import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner'; +import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; +import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; +import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; + +// Hard-coded because the matching STANDARD_OBJECTS entries no longer exist +// in twenty-shared after the favorite → navigationMenuItem migration. +const FAVORITE_OBJECT_UNIVERSAL_IDENTIFIER = + '20202020-ab56-4e05-92a3-e2414a499860'; +const FAVORITE_FOLDER_OBJECT_UNIVERSAL_IDENTIFIER = + '20202020-7cf8-401f-8211-a9587d27fd2d'; + +// favorite has a relation to favoriteFolder, so it must be deleted first to +// avoid leaving dangling relation fields when favoriteFolder is dropped. +const LEGACY_FAVORITE_OBJECTS: Array<{ + universalIdentifier: string; + label: string; +}> = [ + { + universalIdentifier: FAVORITE_OBJECT_UNIVERSAL_IDENTIFIER, + label: 'favorite', + }, + { + universalIdentifier: FAVORITE_FOLDER_OBJECT_UNIVERSAL_IDENTIFIER, + label: 'favoriteFolder', + }, +]; + +@RegisteredWorkspaceCommand('2.7.0', 1798000030000) +@Command({ + name: 'upgrade:2-7:drop-favorite-objects', + description: + 'Drop leftover favorite and favoriteFolder object metadata and workspace tables (data was migrated to navigationMenuItem in 1.17/1.18)', +}) +export class DropFavoriteObjectsCommand extends ActiveOrSuspendedWorkspaceCommandRunner { + constructor( + protected readonly workspaceIteratorService: WorkspaceIteratorService, + private readonly applicationService: ApplicationService, + private readonly objectMetadataService: ObjectMetadataService, + private readonly workspaceCacheService: WorkspaceCacheService, + ) { + super(workspaceIteratorService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + const isDryRun = options.dryRun ?? false; + + this.logger.log( + `${isDryRun ? '[DRY RUN] ' : ''}Starting legacy favorite objects removal for workspace ${workspaceId}`, + ); + + const { twentyStandardFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ); + + const { flatObjectMetadataMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatObjectMetadataMaps', + ]); + + for (const { universalIdentifier, label } of LEGACY_FAVORITE_OBJECTS) { + const flatObjectMetadata = + findFlatEntityByUniversalIdentifier({ + flatEntityMaps: flatObjectMetadataMaps, + universalIdentifier, + }); + + if (!isDefined(flatObjectMetadata)) { + this.logger.log( + `${label} object already absent for workspace ${workspaceId}`, + ); + continue; + } + + if (isDryRun) { + this.logger.log( + `[DRY RUN] Would delete ${label} object (id=${flatObjectMetadata.id}) for workspace ${workspaceId}`, + ); + continue; + } + + await this.objectMetadataService.deleteOneObject({ + deleteObjectInput: { id: flatObjectMetadata.id }, + workspaceId, + isSystemBuild: true, + ownerFlatApplication: twentyStandardFlatApplication, + }); + + this.logger.log(`Deleted ${label} object for workspace ${workspaceId}`); + } + } +}