From f20d04eb6e4047aadc6d841dc221366dd82ca84f Mon Sep 17 00:00:00 2001 From: martmull Date: Fri, 5 Jun 2026 16:35:50 +0200 Subject: [PATCH] feat(app-dev): surface metadata diff in dev sync and name failing migration actions (#21249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split out of #21240. - Render the applied metadata changes (created/updated/deleted + identifiers) in the dev sync output instead of a bare `✓ Synced`. - Include the failing entity's `universalIdentifier` in `WorkspaceMigrationRunnerException` messages so conflicts are diagnosable. image image image --------- Co-authored-by: Charles Bochet --- .../src/cli/utilities/api/api-service.ts | 8 +- .../src/cli/utilities/api/application-api.ts | 8 +- .../format-sync-actions-summary.spec.ts | 92 +++++++++++++++++++ ...sync-application-orchestrator-step.spec.ts | 85 +++++++++++++++++ .../steps/format-sync-actions-summary.ts | 68 ++++++++++++++ .../sync-application-orchestrator-step.ts | 4 + .../dtos/workspace-migration.dto.ts | 3 +- ...pgrade-error-for-storage.util.spec.ts.snap | 2 +- ...mat-upgrade-error-for-storage.util.spec.ts | 1 + ...rkspace-migration-runner.exception.spec.ts | 45 +++++++++ .../workspace-migration-runner.exception.ts | 26 +++++- packages/twenty-shared/src/metadata/index.ts | 1 + .../src/metadata/types/sync-action.type.ts | 26 ++++++ 13 files changed, 364 insertions(+), 5 deletions(-) create mode 100644 packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/__tests__/format-sync-actions-summary.spec.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/__tests__/sync-application-orchestrator-step.spec.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/__tests__/workspace-migration-runner.exception.spec.ts create mode 100644 packages/twenty-shared/src/metadata/types/sync-action.type.ts diff --git a/packages/twenty-sdk/src/cli/utilities/api/api-service.ts b/packages/twenty-sdk/src/cli/utilities/api/api-service.ts index fbf403f63d..77d3beacdf 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/api-service.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/api-service.ts @@ -5,6 +5,7 @@ import { FileApi } from '@/cli/utilities/api/file-api'; import { LogicFunctionApi } from '@/cli/utilities/api/logic-function-api'; import { SchemaApi } from '@/cli/utilities/api/schema-api'; import { type Manifest } from 'twenty-shared/application'; +import { type SyncAction } from 'twenty-shared/metadata'; type ApiServiceOptions = { disableInterceptors?: boolean; @@ -72,7 +73,12 @@ export class ApiService { return this.applicationApi.createDevelopmentApplication(...args); } - syncApplication(manifest: Manifest): Promise { + syncApplication(manifest: Manifest): Promise< + ApiResponse<{ + applicationUniversalIdentifier: string; + actions: SyncAction[]; + }> + > { return this.applicationApi.syncApplication(manifest); } diff --git a/packages/twenty-sdk/src/cli/utilities/api/application-api.ts b/packages/twenty-sdk/src/cli/utilities/api/application-api.ts index aac5f1dc39..4956649c6d 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/application-api.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/application-api.ts @@ -1,6 +1,7 @@ import { type ApiResponse } from '@/cli/utilities/api/api-response-type'; import axios, { type AxiosInstance, type AxiosResponse } from 'axios'; import { type Manifest } from 'twenty-shared/application'; +import { type SyncAction } from 'twenty-shared/metadata'; export class ApplicationApi { constructor(private readonly client: AxiosInstance) {} @@ -251,7 +252,12 @@ export class ApplicationApi { } } - async syncApplication(manifest: Manifest): Promise { + async syncApplication(manifest: Manifest): Promise< + ApiResponse<{ + applicationUniversalIdentifier: string; + actions: SyncAction[]; + }> + > { try { const mutation = ` mutation SyncApplication($manifest: JSON!) { diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/__tests__/format-sync-actions-summary.spec.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/__tests__/format-sync-actions-summary.spec.ts new file mode 100644 index 0000000000..aedd31a309 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/__tests__/format-sync-actions-summary.spec.ts @@ -0,0 +1,92 @@ +import { type SyncAction } from 'twenty-shared/metadata'; +import { describe, expect, it } from 'vitest'; + +import { formatSyncActionsSummary } from '@/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary'; + +describe('formatSyncActionsSummary', () => { + it('reports no changes when the actions list is empty', () => { + expect(formatSyncActionsSummary([])).toEqual([ + { message: 'No metadata changes', status: 'info' }, + ]); + }); + + it('summarizes created, updated and deleted actions with their identifiers', () => { + const events = formatSyncActionsSummary([ + { + type: 'create', + metadataName: 'objectMetadata', + flatEntity: { + universalIdentifier: 'uid-object', + nameSingular: 'rocket', + }, + }, + { + type: 'create', + metadataName: 'fieldMetadata', + flatEntity: { + universalIdentifier: 'uid-field', + name: 'timelineActivities', + }, + }, + { + type: 'update', + metadataName: 'fieldMetadata', + universalIdentifier: 'uid-updated-field', + }, + { + type: 'delete', + metadataName: 'pageLayout', + universalIdentifier: 'uid-page-layout', + }, + ]); + + expect(events).toEqual([ + { + message: 'Metadata changes: 2 created, 1 updated, 1 deleted', + status: 'info', + }, + { message: ' created objectMetadata rocket', status: 'info' }, + { message: ' created fieldMetadata timelineActivities', status: 'info' }, + { message: ' updated fieldMetadata uid-updated-field', status: 'info' }, + { message: ' deleted pageLayout uid-page-layout', status: 'info' }, + ]); + }); + + it('falls back to the universal identifier when a created entity has no name', () => { + const events = formatSyncActionsSummary([ + { + type: 'create', + metadataName: 'fieldMetadata', + flatEntity: { universalIdentifier: 'uid-nameless' }, + }, + ]); + + expect(events).toEqual([ + { message: 'Metadata changes: 1 created', status: 'info' }, + { message: ' created fieldMetadata uid-nameless', status: 'info' }, + ]); + }); + + it('truncates the detail lines when there are more changes than the display limit', () => { + const actions: SyncAction[] = Array.from({ length: 55 }, (_, index) => ({ + type: 'create' as const, + metadataName: 'fieldMetadata' as const, + flatEntity: { + universalIdentifier: `uid-${index}`, + name: `field${index}`, + }, + })); + + const events = formatSyncActionsSummary(actions); + + expect(events[0]).toEqual({ + message: 'Metadata changes: 55 created', + status: 'info', + }); + expect(events).toHaveLength(52); + expect(events[51]).toEqual({ + message: ' …and 5 more change(s)', + status: 'info', + }); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/__tests__/sync-application-orchestrator-step.spec.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/__tests__/sync-application-orchestrator-step.spec.ts new file mode 100644 index 0000000000..b039d2e4be --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/__tests__/sync-application-orchestrator-step.spec.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { type ApiService } from '@/cli/utilities/api/api-service'; +import { OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state'; +import { SyncApplicationOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step'; +import { type Manifest } from 'twenty-shared/application'; + +vi.mock('@/cli/utilities/build/manifest/manifest-update-checksums', () => ({ + manifestUpdateChecksums: ({ manifest }: { manifest: unknown }) => manifest, +})); + +vi.mock('@/cli/utilities/build/manifest/manifest-writer', () => ({ + writeManifestToOutput: vi.fn(), +})); + +const buildStep = ( + syncApplication: ApiService['syncApplication'], +): { state: OrchestratorState; step: SyncApplicationOrchestratorStep } => { + const state = new OrchestratorState({ appPath: '/tmp/app' }); + + const apiService = { syncApplication } as unknown as ApiService; + + const step = new SyncApplicationOrchestratorStep({ + apiService, + state, + notify: () => {}, + }); + + return { state, step }; +}; + +const executeInput = { + manifest: { + application: { displayName: 'Demo' }, + } as unknown as Manifest, + builtFileInfos: new Map(), + appPath: '/tmp/app', +}; + +describe('SyncApplicationOrchestratorStep', () => { + it('renders the applied metadata changes on a successful sync', async () => { + const syncApplication = vi.fn().mockResolvedValue({ + success: true, + data: { + applicationUniversalIdentifier: 'app-uid', + actions: [ + { + type: 'create', + metadataName: 'fieldMetadata', + flatEntity: { + universalIdentifier: 'uid-field', + name: 'timelineActivities', + }, + }, + ], + }, + }); + + const { state, step } = buildStep(syncApplication); + + await step.execute(executeInput); + + const messages = state.events.map((event) => event.message); + + expect(messages).toContain('Metadata changes: 1 created'); + expect(messages).toContain(' created fieldMetadata timelineActivities'); + expect(messages).toContain('✓ Synced'); + }); + + it('reports no metadata changes when the sync applies nothing', async () => { + const syncApplication = vi.fn().mockResolvedValue({ + success: true, + data: { applicationUniversalIdentifier: 'app-uid', actions: [] }, + }); + + const { state, step } = buildStep(syncApplication); + + await step.execute(executeInput); + + const messages = state.events.map((event) => event.message); + + expect(messages).toContain('No metadata changes'); + expect(messages).toContain('✓ Synced'); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary.ts new file mode 100644 index 0000000000..074924ebc8 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary.ts @@ -0,0 +1,68 @@ +import { type OrchestratorStateStepEvent } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state'; +import { type SyncAction } from 'twenty-shared/metadata'; +import { isDefined } from 'twenty-shared/utils'; + +const MAX_DETAIL_LINES = 50; + +const VERB_BY_TYPE = { + create: 'created', + update: 'updated', + delete: 'deleted', +} as const; + +const getActionLabel = (action: SyncAction): string => { + if (action.type === 'create') { + return ( + action.flatEntity?.name ?? + action.flatEntity?.nameSingular ?? + action.flatEntity?.universalIdentifier ?? + 'unknown' + ); + } + + return action.universalIdentifier; +}; + +export const formatSyncActionsSummary = ( + actions: SyncAction[], +): OrchestratorStateStepEvent[] => { + if (actions.length === 0) { + return [{ message: 'No metadata changes', status: 'info' }]; + } + + const counts = { create: 0, update: 0, delete: 0 }; + + for (const action of actions) { + counts[action.type] += 1; + } + + const summaryParts = [ + counts.create > 0 ? `${counts.create} created` : null, + counts.update > 0 ? `${counts.update} updated` : null, + counts.delete > 0 ? `${counts.delete} deleted` : null, + ].filter(isDefined); + + const events: OrchestratorStateStepEvent[] = [ + { message: `Metadata changes: ${summaryParts.join(', ')}`, status: 'info' }, + ]; + + const visibleActions = actions.slice(0, MAX_DETAIL_LINES); + + for (const action of visibleActions) { + events.push({ + message: ` ${VERB_BY_TYPE[action.type]} ${action.metadataName} ${getActionLabel(action)}`, + status: 'info', + }); + } + + const hiddenCount = actions.length - visibleActions.length; + + if (hiddenCount > 0) { + events.push({ + message: ` …and ${hiddenCount} more change(s)`, + status: 'info', + }); + } + + return events; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step.ts index e24362e20a..d70f4ba81b 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step.ts @@ -7,6 +7,7 @@ import { type OrchestratorStateStepEvent, type OrchestratorStateSyncStatus, } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state'; +import { formatSyncActionsSummary } from '@/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary'; import { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors'; import { serializeError } from '@/cli/utilities/error/serialize-error'; import { type Manifest } from 'twenty-shared/application'; @@ -69,6 +70,9 @@ export class SyncApplicationOrchestratorStep { const syncResult = await this.apiService.syncApplication(manifest); if (syncResult.success) { + const syncData = syncResult.data; + + events.push(...formatSyncActionsSummary(syncData.actions)); events.push({ message: '✓ Synced', status: 'success' }); step.output = { syncStatus: 'synced', error: null }; step.status = 'done'; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-development/dtos/workspace-migration.dto.ts b/packages/twenty-server/src/engine/core-modules/application/application-development/dtos/workspace-migration.dto.ts index 047e7cc958..269e999e3b 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-development/dtos/workspace-migration.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-development/dtos/workspace-migration.dto.ts @@ -1,6 +1,7 @@ import { Field, ObjectType } from '@nestjs/graphql'; import GraphQLJSON from 'graphql-type-json'; +import { type SyncAction } from 'twenty-shared/metadata'; @ObjectType('WorkspaceMigration') export class WorkspaceMigrationDTO { @@ -8,5 +9,5 @@ export class WorkspaceMigrationDTO { applicationUniversalIdentifier: string; @Field(() => GraphQLJSON) - actions: unknown[]; + actions: SyncAction[]; } diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/__snapshots__/format-upgrade-error-for-storage.util.spec.ts.snap b/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/__snapshots__/format-upgrade-error-for-storage.util.spec.ts.snap index 271c8b93f1..d08776ee4f 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/__snapshots__/format-upgrade-error-for-storage.util.spec.ts.snap +++ b/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/__snapshots__/format-upgrade-error-for-storage.util.spec.ts.snap @@ -31,7 +31,7 @@ Report: { `; exports[`formatUpgradeErrorForStorage should format a WorkspaceMigrationRunnerException with EXECUTION_FAILED 1`] = ` -"[WorkspaceMigrationRunnerException] Migration action 'create' for 'objectMetadata' failed +"[WorkspaceMigrationRunnerException] Migration action 'create' for 'objectMetadata' (universalIdentifier: test-object-uid) failed Code: EXECUTION_FAILED Action: create on objectMetadata Metadata error: column "label" cannot be null diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/format-upgrade-error-for-storage.util.spec.ts b/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/format-upgrade-error-for-storage.util.spec.ts index 944a6241a5..da3969fafe 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/format-upgrade-error-for-storage.util.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/format-upgrade-error-for-storage.util.spec.ts @@ -57,6 +57,7 @@ describe('formatUpgradeErrorForStorage', () => { const action = { type: 'create', metadataName: 'objectMetadata', + flatEntity: { universalIdentifier: 'test-object-uid' }, } as unknown as AllUniversalWorkspaceMigrationAction; const error = new WorkspaceMigrationRunnerException({ diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/__tests__/workspace-migration-runner.exception.spec.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/__tests__/workspace-migration-runner.exception.spec.ts new file mode 100644 index 0000000000..9f6b7cb491 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/__tests__/workspace-migration-runner.exception.spec.ts @@ -0,0 +1,45 @@ +import { type AllUniversalWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common'; +import { + WorkspaceMigrationRunnerException, + WorkspaceMigrationRunnerExceptionCode, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception'; + +describe('WorkspaceMigrationRunnerException', () => { + it('includes the universal identifier of a failed create action in the message', () => { + const action = { + type: 'create', + metadataName: 'fieldMetadata', + flatEntity: { + universalIdentifier: '20202020-6736-4337-b5c4-8b39fae325a5', + }, + } as unknown as AllUniversalWorkspaceMigrationAction; + + const exception = new WorkspaceMigrationRunnerException({ + code: WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED, + action, + errors: {}, + }); + + expect(exception.message).toBe( + "Migration action 'create' for 'fieldMetadata' (universalIdentifier: 20202020-6736-4337-b5c4-8b39fae325a5) failed", + ); + }); + + it('includes the universal identifier of a failed delete action in the message', () => { + const action = { + type: 'delete', + metadataName: 'pageLayout', + universalIdentifier: 'uid-page-layout', + } as unknown as AllUniversalWorkspaceMigrationAction; + + const exception = new WorkspaceMigrationRunnerException({ + code: WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED, + action, + errors: {}, + }); + + expect(exception.message).toBe( + "Migration action 'delete' for 'pageLayout' (universalIdentifier: uid-page-layout) failed", + ); + }); +}); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception.ts index 2333366659..f141bf62d7 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception.ts @@ -34,6 +34,25 @@ export type WorkspaceMigrationRunnerExecutionErrors = { actionTranspilation?: Error; }; +const getActionUniversalIdentifierOrThrow = ( + action: AllUniversalWorkspaceMigrationAction, +): string => { + if (action.type === 'create') { + const universalIdentifier = action.flatEntity?.universalIdentifier; + + if (!universalIdentifier) { + throw new WorkspaceMigrationRunnerException({ + message: `Missing universalIdentifier on create action for '${action.metadataName}'`, + code: WorkspaceMigrationRunnerExceptionCode.INTERNAL_SERVER_ERROR, + }); + } + + return universalIdentifier; + } + + return action.universalIdentifier; +}; + const { // oxlint-disable-next-line unused-imports/no-unused-vars EXECUTION_FAILED: WorkspaceMigrationRunnerExceptionExecutionFailedCode, @@ -62,8 +81,13 @@ export class WorkspaceMigrationRunnerException extends CustomError { constructor(args: WorkspaceMigrationRunnerExceptionConstructorArgs) { if (args.code === WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED) { + const universalIdentifier = getActionUniversalIdentifierOrThrow( + args.action, + ); + const identifierClause = ` (universalIdentifier: ${universalIdentifier})`; + super( - `Migration action '${args.action.type}' for '${args.action.metadataName}' failed`, + `Migration action '${args.action.type}' for '${args.action.metadataName}'${identifierClause} failed`, ); this.code = args.code; diff --git a/packages/twenty-shared/src/metadata/index.ts b/packages/twenty-shared/src/metadata/index.ts index 92859e1db1..22430415fd 100644 --- a/packages/twenty-shared/src/metadata/index.ts +++ b/packages/twenty-shared/src/metadata/index.ts @@ -25,6 +25,7 @@ export type { MetadataValidationErrorResponse, } from './types/MetadataValidationError'; export { WorkspaceMigrationV2ExceptionCode } from './types/MetadataValidationError'; +export type { SyncAction } from './types/sync-action.type'; export { addCustomSuffixIfIsReserved } from './utils/add-custom-suffix-if-reserved.util'; export { computeMetadataNameFromLabel } from './utils/compute-metadata-name-from-label.util'; export { computeMetadataNamesFromLabelsOrThrow } from './utils/compute-metadata-names-from-labels-or-throw.util'; diff --git a/packages/twenty-shared/src/metadata/types/sync-action.type.ts b/packages/twenty-shared/src/metadata/types/sync-action.type.ts new file mode 100644 index 0000000000..da365f5d3f --- /dev/null +++ b/packages/twenty-shared/src/metadata/types/sync-action.type.ts @@ -0,0 +1,26 @@ +import { type AllMetadataName } from '@/metadata/types/all-metadata-name.type'; + +type SyncCreateAction = { + type: 'create'; + metadataName: AllMetadataName; + flatEntity?: { + name?: string | null; + nameSingular?: string | null; + universalIdentifier?: string | null; + [key: string]: unknown; + }; +}; + +type SyncUpdateAction = { + type: 'update'; + metadataName: AllMetadataName; + universalIdentifier: string; +}; + +type SyncDeleteAction = { + type: 'delete'; + metadataName: AllMetadataName; + universalIdentifier: string; +}; + +export type SyncAction = SyncCreateAction | SyncUpdateAction | SyncDeleteAction;