From 6c65d26ceddd5bfcf24fa9fc69e4ae67c006dee3 Mon Sep 17 00:00:00 2001 From: martmull Date: Fri, 5 Jun 2026 19:49:02 +0200 Subject: [PATCH] feat(app-dev): add dry-run preview to dev sync (#21251) Split out of #21240. Stacked on #21250 (review/merge that first). `yarn twenty dev --once --dry-run` computes the migration plan and prints the diff **without applying anything** (no migration, no app-record update, no SDK generation). Also renders the diff on a normal `dev --once` sync. image --- .../src/metadata/generated/schema.graphql | 2 +- .../src/metadata/generated/schema.ts | 2 +- .../src/metadata/generated/types.ts | 3 + .../src/generated-metadata/graphql.ts | 1 + .../src/cli/commands/dev/dev-once.ts | 18 +++- .../twenty-sdk/src/cli/commands/dev/index.ts | 10 ++- .../twenty-sdk/src/cli/operations/dev-once.ts | 57 ++++++++++++- .../src/cli/utilities/api/api-service.ts | 7 +- .../src/cli/utilities/api/application-api.ts | 11 ++- .../format-sync-actions-summary.spec.ts | 6 ++ .../steps/format-sync-actions-summary.ts | 12 +-- .../application-development.resolver.ts | 17 +++- .../dtos/application.input.ts | 3 + .../application-manifest-migration.service.ts | 17 ++-- .../application-sync.service.ts | 42 ++++------ ...igration-validate-build-and-run-service.ts | 6 +- .../dry-run-manifest-sync.integration-spec.ts | 84 +++++++++++++++++++ .../sync-application-query-factory.util.ts | 7 +- .../utils/sync-application.util.ts | 3 + 19 files changed, 257 insertions(+), 51 deletions(-) create mode 100644 packages/twenty-server/test/integration/metadata/suites/application/dry-run-manifest-sync.integration-spec.ts diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 8da058591b1..a85da3564a9 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -3360,7 +3360,7 @@ type Mutation { syncMarketplaceCatalog: Boolean! createDevelopmentApplication(universalIdentifier: String!, name: String!): DevelopmentApplication! generateApplicationToken(applicationId: UUID!): ApplicationTokenPair! - syncApplication(manifest: JSON!): WorkspaceMigration! + syncApplication(manifest: JSON!, dryRun: Boolean): WorkspaceMigration! uploadApplicationFile(file: Upload!, applicationUniversalIdentifier: String!, fileFolder: FileFolder!, filePath: String!): File! upgradeApplication(appRegistrationId: String!, targetVersion: String!): Boolean! renewApplicationToken(applicationRefreshToken: String!): ApplicationTokenPair! diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index a77795ab26a..166b651c1fe 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -5972,7 +5972,7 @@ export interface MutationGenqlSelection{ syncMarketplaceCatalog?: boolean | number createDevelopmentApplication?: (DevelopmentApplicationGenqlSelection & { __args: {universalIdentifier: Scalars['String'], name: Scalars['String']} }) generateApplicationToken?: (ApplicationTokenPairGenqlSelection & { __args: {applicationId: Scalars['UUID']} }) - syncApplication?: (WorkspaceMigrationGenqlSelection & { __args: {manifest: Scalars['JSON']} }) + syncApplication?: (WorkspaceMigrationGenqlSelection & { __args: {manifest: Scalars['JSON'], dryRun?: (Scalars['Boolean'] | null)} }) uploadApplicationFile?: (FileGenqlSelection & { __args: {file: Scalars['Upload'], applicationUniversalIdentifier: Scalars['String'], fileFolder: FileFolder, filePath: Scalars['String']} }) upgradeApplication?: { __args: {appRegistrationId: Scalars['String'], targetVersion: Scalars['String']} } renewApplicationToken?: (ApplicationTokenPairGenqlSelection & { __args: {applicationRefreshToken: Scalars['String']} }) diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index 1f212513918..4c50b1547aa 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -8761,6 +8761,9 @@ export default { "manifest": [ 15, "JSON!" + ], + "dryRun": [ + 6 ] } ], diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 9bee2cdcb23..377f8aff3a3 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -3315,6 +3315,7 @@ export type MutationStopAgentChatStreamArgs = { export type MutationSyncApplicationArgs = { + dryRun?: InputMaybe; manifest: Scalars['JSON']; }; diff --git a/packages/twenty-sdk/src/cli/commands/dev/dev-once.ts b/packages/twenty-sdk/src/cli/commands/dev/dev-once.ts index daf64950cc3..43fd5bbe98c 100644 --- a/packages/twenty-sdk/src/cli/commands/dev/dev-once.ts +++ b/packages/twenty-sdk/src/cli/commands/dev/dev-once.ts @@ -7,6 +7,7 @@ import chalk from 'chalk'; export type AppDevOnceCommandOptions = { appPath?: string; verbose?: boolean; + dryRun?: boolean; }; export class AppDevOnceCommand { @@ -17,12 +18,17 @@ export class AppDevOnceCommand { const remoteName = ConfigService.getActiveRemote(); - console.log(chalk.blue(`Syncing application on ${remoteName}...`)); + console.log( + chalk.blue( + `${options.dryRun ? 'Previewing application diff' : 'Syncing application'} on ${remoteName}...`, + ), + ); console.log(chalk.gray(`App path: ${appPath}\n`)); const result = await appDevOnce({ appPath, verbose: options.verbose, + dryRun: options.dryRun, onProgress: (message) => console.log(chalk.gray(message)), }); @@ -31,6 +37,16 @@ export class AppDevOnceCommand { process.exit(1); } + if (options.dryRun) { + console.log( + chalk.green( + `\nāœ“ Dry run complete for ${result.data.applicationDisplayName} — no changes were applied`, + ), + ); + + return; + } + console.log( chalk.green( `\nāœ“ Synced ${result.data.applicationDisplayName} (${result.data.fileCount} file${result.data.fileCount === 1 ? '' : 's'})`, diff --git a/packages/twenty-sdk/src/cli/commands/dev/index.ts b/packages/twenty-sdk/src/cli/commands/dev/index.ts index 4acf4137246..283cfb7b53c 100644 --- a/packages/twenty-sdk/src/cli/commands/dev/index.ts +++ b/packages/twenty-sdk/src/cli/commands/dev/index.ts @@ -22,6 +22,7 @@ export const registerDevCommands = (program: Command): void => { verbose?: boolean; debug?: boolean; debounceMs?: string; + dryRun?: boolean; }, ) => { const commonOptions = { @@ -33,7 +34,10 @@ export const registerDevCommands = (program: Command): void => { }; if (options.once) { - await devOnceCommand.execute(commonOptions); + await devOnceCommand.execute({ + ...commonOptions, + dryRun: options.dryRun, + }); return; } @@ -48,6 +52,10 @@ export const registerDevCommands = (program: Command): void => { '-o, --once', 'Build and sync once, then exit (useful for CI, scripts, and pre-commit hooks)', ) + .option( + '--dry-run', + 'Preview the metadata changes without applying them (requires --once)', + ) .option('--debounceMs ', 'Debounce in ms (default: 2 000)') .option('-v, --verbose', 'Show detailed logs') .option('-d, --debug', 'Show detailed logs (alias for --verbose)') diff --git a/packages/twenty-sdk/src/cli/operations/dev-once.ts b/packages/twenty-sdk/src/cli/operations/dev-once.ts index a308ac45448..e5a4c85583e 100644 --- a/packages/twenty-sdk/src/cli/operations/dev-once.ts +++ b/packages/twenty-sdk/src/cli/operations/dev-once.ts @@ -1,5 +1,6 @@ import path from 'path'; import { OUTPUT_DIR, type Manifest } from 'twenty-shared/application'; +import { type SyncAction } from 'twenty-shared/metadata'; import { ApiService } from '@/cli/utilities/api/api-service'; import { @@ -13,6 +14,7 @@ import { manifestUpdateChecksums } from '@/cli/utilities/build/manifest/manifest import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer'; import { ClientService } from '@/cli/utilities/client/client-service'; import { ConfigService } from '@/cli/utilities/config/config-service'; +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 { FileUploader } from '@/cli/utilities/file/file-uploader'; @@ -22,6 +24,7 @@ import { APP_ERROR_CODES, type CommandResult } from '@/cli/types'; export type AppDevOnceOptions = { appPath: string; verbose?: boolean; + dryRun?: boolean; onProgress?: (message: string) => void; }; @@ -32,10 +35,19 @@ export type AppDevOnceResult = { applicationUniversalIdentifier: string; }; +const reportMetadataChanges = ( + data: { actions: SyncAction[] }, + onProgress?: (message: string) => void, +): void => { + for (const event of formatSyncActionsSummary(data.actions)) { + onProgress?.(event.message); + } +}; + const innerAppDevOnce = async ( options: AppDevOnceOptions, ): Promise> => { - const { appPath, onProgress, verbose } = options; + const { appPath, onProgress, verbose, dryRun } = options; onProgress?.('Checking server...'); @@ -120,6 +132,47 @@ const innerAppDevOnce = async ( await writeManifestToOutput(appPath, manifest); + if (dryRun) { + onProgress?.( + 'Computing metadata diff (dry run, nothing will be applied)...', + ); + + const dryRunResult = await apiService.syncApplication(manifest, { + dryRun: true, + }); + + if (!dryRunResult.success) { + const errorEvents = verbose + ? null + : formatManifestValidationErrors(dryRunResult.error); + + const message = errorEvents + ? errorEvents.map((event) => event.message).join('\n') + : `Dry run failed with error: ${serializeError(dryRunResult.error)}`; + + return { + success: false, + error: { + code: APP_ERROR_CODES.SYNC_FAILED, + message, + }, + }; + } + + reportMetadataChanges(dryRunResult.data, onProgress); + + return { + success: true, + data: { + outputDir: path.join(appPath, OUTPUT_DIR), + fileCount: buildResult.builtFileInfos.size, + applicationDisplayName: manifest.application.displayName, + applicationUniversalIdentifier: + manifest.application.universalIdentifier, + }, + }; + } + onProgress?.('Registering application...'); const configService = new ConfigService(); @@ -212,6 +265,8 @@ const innerAppDevOnce = async ( }; } + reportMetadataChanges(syncResult.data, onProgress); + onProgress?.('Generating API client...'); try { 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 77d3beacdfc..0e2a3bbf05e 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/api-service.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/api-service.ts @@ -73,13 +73,16 @@ export class ApiService { return this.applicationApi.createDevelopmentApplication(...args); } - syncApplication(manifest: Manifest): Promise< + syncApplication( + manifest: Manifest, + options?: { dryRun?: boolean }, + ): Promise< ApiResponse<{ applicationUniversalIdentifier: string; actions: SyncAction[]; }> > { - return this.applicationApi.syncApplication(manifest); + return this.applicationApi.syncApplication(manifest, options); } uninstallApplication(universalIdentifier: string): Promise { 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 4956649c6d7..e2f5f7b6999 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/application-api.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/application-api.ts @@ -252,7 +252,10 @@ export class ApplicationApi { } } - async syncApplication(manifest: Manifest): Promise< + async syncApplication( + manifest: Manifest, + options?: { dryRun?: boolean }, + ): Promise< ApiResponse<{ applicationUniversalIdentifier: string; actions: SyncAction[]; @@ -260,15 +263,15 @@ export class ApplicationApi { > { try { const mutation = ` - mutation SyncApplication($manifest: JSON!) { - syncApplication(manifest: $manifest) { + mutation SyncApplication($manifest: JSON!, $dryRun: Boolean) { + syncApplication(manifest: $manifest, dryRun: $dryRun) { applicationUniversalIdentifier actions } } `; - const variables = { manifest }; + const variables = { manifest, dryRun: options?.dryRun ?? false }; const response: AxiosResponse = await this.client.post( '/metadata', 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 index aedd31a309c..c4faf2b77f2 100644 --- 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 @@ -10,6 +10,12 @@ describe('formatSyncActionsSummary', () => { ]); }); + it('reports no changes when actions are missing from the response', () => { + expect(formatSyncActionsSummary(undefined)).toEqual([ + { message: 'No metadata changes', status: 'info' }, + ]); + }); + it('summarizes created, updated and deleted actions with their identifiers', () => { const events = formatSyncActionsSummary([ { 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 index 074924ebc85..4b9ab96930c 100644 --- 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 @@ -24,15 +24,17 @@ const getActionLabel = (action: SyncAction): string => { }; export const formatSyncActionsSummary = ( - actions: SyncAction[], + actions: SyncAction[] | undefined, ): OrchestratorStateStepEvent[] => { - if (actions.length === 0) { + const definedActions = actions ?? []; + + if (definedActions.length === 0) { return [{ message: 'No metadata changes', status: 'info' }]; } const counts = { create: 0, update: 0, delete: 0 }; - for (const action of actions) { + for (const action of definedActions) { counts[action.type] += 1; } @@ -46,7 +48,7 @@ export const formatSyncActionsSummary = ( { message: `Metadata changes: ${summaryParts.join(', ')}`, status: 'info' }, ]; - const visibleActions = actions.slice(0, MAX_DETAIL_LINES); + const visibleActions = definedActions.slice(0, MAX_DETAIL_LINES); for (const action of visibleActions) { events.push({ @@ -55,7 +57,7 @@ export const formatSyncActionsSummary = ( }); } - const hiddenCount = actions.length - visibleActions.length; + const hiddenCount = definedActions.length - visibleActions.length; if (hiddenCount > 0) { events.push({ diff --git a/packages/twenty-server/src/engine/core-modules/application/application-development/application-development.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/application-development/application-development.resolver.ts index 2c21f4ccb90..60b3b57080f 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-development/application-development.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-development/application-development.resolver.ts @@ -133,7 +133,7 @@ export class ApplicationDevelopmentResolver { @Mutation(() => WorkspaceMigrationDTO) async syncApplication( - @Args() { manifest }: ApplicationInput, + @Args() { manifest, dryRun }: ApplicationInput, @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, ): Promise { await this.throttlePerApplication( @@ -141,6 +141,21 @@ export class ApplicationDevelopmentResolver { workspaceId, ); + if (dryRun === true) { + const { workspaceMigration } = + await this.applicationSyncService.synchronizeFromManifest({ + workspaceId, + manifest, + dryRun: true, + }); + + return { + applicationUniversalIdentifier: + workspaceMigration.applicationUniversalIdentifier, + actions: workspaceMigration.actions, + }; + } + return this.cacheLockService.withLock( () => this.applyManifestSync(manifest, workspaceId), `app-sync:${workspaceId}`, diff --git a/packages/twenty-server/src/engine/core-modules/application/application-development/dtos/application.input.ts b/packages/twenty-server/src/engine/core-modules/application/application-development/dtos/application.input.ts index d25ffae4de7..74d4a3d6b2c 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-development/dtos/application.input.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-development/dtos/application.input.ts @@ -7,4 +7,7 @@ import { Manifest } from 'twenty-shared/application'; export class ApplicationInput { @Field(() => GraphQLJSON, { nullable: false }) manifest: Manifest; + + @Field(() => Boolean, { nullable: true }) + dryRun?: boolean; } diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest-migration.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest-migration.service.ts index 4204dc41420..e8e4f0f2677 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest-migration.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest-migration.service.ts @@ -162,10 +162,12 @@ export class ApplicationManifestMigrationService { manifest, workspaceId, ownerFlatApplication, + dryRun = false, }: { manifest: Manifest; workspaceId: string; ownerFlatApplication: FlatApplication; + dryRun?: boolean; }): Promise<{ workspaceMigration: WorkspaceMigration; hasSchemaMetadataChanged: boolean; @@ -225,6 +227,7 @@ export class ApplicationManifestMigrationService { workspaceId, dependencyAllFlatEntityMaps, additionalCacheDataMaps: { featureFlagsMap }, + dryRun, }, ); @@ -236,14 +239,16 @@ export class ApplicationManifestMigrationService { } this.logger.log( - `Metadata migration completed for application ${ownerFlatApplication.universalIdentifier}`, + `Metadata migration ${dryRun ? 'plan computed (dry run)' : 'completed'} for application ${ownerFlatApplication.universalIdentifier}`, ); - await this.syncDefaultRoleAndSettingsCustomTab({ - manifest, - workspaceId, - ownerFlatApplication, - }); + if (!dryRun) { + await this.syncDefaultRoleAndSettingsCustomTab({ + manifest, + workspaceId, + ownerFlatApplication, + }); + } return { workspaceMigration: validateAndBuildResult.workspaceMigration, diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts index fe5897ba41b..74acaee185b 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts @@ -41,30 +41,38 @@ export class ApplicationSyncService { workspaceId, manifest, applicationRegistrationId, + dryRun = false, }: { workspaceId: string; manifest: Manifest; applicationRegistrationId?: string; + dryRun?: boolean; }): Promise<{ workspaceMigration: WorkspaceMigration; hasSchemaMetadataChanged: boolean; }> { - const application = await this.syncApplication({ - workspaceId, - manifest, - applicationRegistrationId, - }); - - const ownerFlatApplication: FlatApplication = application; + const ownerFlatApplication: FlatApplication = dryRun + ? await this.applicationService.findOneApplicationOrThrow({ + universalIdentifier: manifest.application.universalIdentifier, + workspaceId, + }) + : await this.syncApplication({ + workspaceId, + manifest, + applicationRegistrationId, + }); const syncResult = await this.applicationManifestMigrationService.syncMetadataFromManifest({ manifest, workspaceId, ownerFlatApplication, + dryRun, }); - this.logger.log('Application sync from manifest completed'); + this.logger.log( + `Application sync from manifest ${dryRun ? 'plan computed (dry run)' : 'completed'}`, + ); return syncResult; } @@ -129,20 +137,13 @@ export class ApplicationSyncService { ).toString('utf-8'), ) as PackageJson; - const application = await this.applicationService.findByUniversalIdentifier( + const application = await this.applicationService.findOneApplicationOrThrow( { universalIdentifier: manifest.application.universalIdentifier, workspaceId, }, ); - if (!application) { - throw new ApplicationException( - `Application "${manifest.application.universalIdentifier}" is not installed in workspace "${workspaceId}". Install it first.`, - ApplicationExceptionCode.APP_NOT_INSTALLED, - ); - } - const resolvedRegistrationId = applicationRegistrationId ?? application.applicationRegistrationId; @@ -165,17 +166,10 @@ export class ApplicationSyncService { workspaceId: string; applicationUniversalIdentifier: string; }): Promise { - const application = await this.applicationService.findByUniversalIdentifier( + const application = await this.applicationService.findOneApplicationOrThrow( { universalIdentifier: applicationUniversalIdentifier, workspaceId }, ); - if (!isDefined(application)) { - throw new ApplicationException( - `Application with universalIdentifier ${applicationUniversalIdentifier} not found`, - ApplicationExceptionCode.ENTITY_NOT_FOUND, - ); - } - if (!application.canBeUninstalled) { throw new ApplicationException( 'This application cannot be uninstalled.', diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service.ts index 713822ad707..1282bd57bf2 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service.ts @@ -358,6 +358,7 @@ export class WorkspaceMigrationValidateBuildAndRunService { public async validateBuildAndRunWorkspaceMigrationFromTo( args: WorkspaceMigrationOrchestratorBuildArgs & { idByUniversalIdentifierByMetadataName?: IdByUniversalIdentifierByMetadataName; + dryRun?: boolean; }, ): Promise< | WorkspaceMigrationOrchestratorFailedResult @@ -365,7 +366,8 @@ export class WorkspaceMigrationValidateBuildAndRunService { hasSchemaMetadataChanged: boolean; }) > { - const { idByUniversalIdentifierByMetadataName, ...buildArgs } = args; + const { idByUniversalIdentifierByMetadataName, dryRun, ...buildArgs } = + args; const validateAndBuildResult = await this.workspaceMigrationBuildOrchestratorService @@ -392,7 +394,7 @@ export class WorkspaceMigrationValidateBuildAndRunService { workspaceMigration: validateAndBuildResult.workspaceMigration, }); - if (workspaceMigration.actions.length === 0) { + if (dryRun === true || workspaceMigration.actions.length === 0) { return { status: 'success', workspaceMigration, diff --git a/packages/twenty-server/test/integration/metadata/suites/application/dry-run-manifest-sync.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/dry-run-manifest-sync.integration-spec.ts new file mode 100644 index 00000000000..e63a61eb5d6 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/dry-run-manifest-sync.integration-spec.ts @@ -0,0 +1,84 @@ +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util'; +import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util'; +import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.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 { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util'; +import { type Manifest } from 'twenty-shared/application'; +import { v4 as uuidv4 } from 'uuid'; + +const TEST_APP_ID = uuidv4(); +const TEST_ROLE_ID = uuidv4(); + +const buildManifest = ( + overrides?: Partial>, +) => buildBaseManifest({ appId: TEST_APP_ID, roleId: TEST_ROLE_ID, overrides }); + +const findCustomObjectNames = async (): Promise => { + const { objects } = await findManyObjectMetadata({ + input: { + filter: { isCustom: { is: true } }, + paging: { first: 100 }, + }, + gqlFields: 'id nameSingular', + expectToFail: false, + }); + + return objects.map((object) => object.nameSingular); +}; + +describe('Manifest sync - dry run', () => { + beforeEach(async () => { + await setupApplicationForSync({ + applicationUniversalIdentifier: TEST_APP_ID, + name: 'Dry Run Test Application', + description: 'App for testing dry-run manifest sync', + sourcePath: 'dry-run-manifest-sync', + }); + }, 60000); + + afterEach(async () => { + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier: TEST_APP_ID, + }); + }); + + it('returns the planned actions without applying them', async () => { + const ticketObject = buildDefaultObjectManifest({ + nameSingular: 'dryRunTicket', + namePlural: 'dryRunTickets', + labelSingular: 'Dry Run Ticket', + labelPlural: 'Dry Run Tickets', + description: 'A support ticket', + }); + + await syncApplication({ + manifest: buildManifest({ objects: [ticketObject] }), + expectToFail: false, + }); + + const invoiceObject = buildDefaultObjectManifest({ + nameSingular: 'dryRunInvoice', + namePlural: 'dryRunInvoices', + labelSingular: 'Dry Run Invoice', + labelPlural: 'Dry Run Invoices', + description: 'A billing invoice', + }); + + const dryRunResponse = await syncApplication({ + manifest: buildManifest({ objects: [ticketObject, invoiceObject] }), + dryRun: true, + expectToFail: false, + }); + + expect(dryRunResponse.errors).toBeUndefined(); + expect(dryRunResponse.data.syncApplication.actions.length).toBeGreaterThan( + 0, + ); + + const customObjectNames = await findCustomObjectNames(); + + expect(customObjectNames).toContain('dryRunTicket'); + expect(customObjectNames).not.toContain('dryRunInvoice'); + }, 60000); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/application/utils/sync-application-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/application/utils/sync-application-query-factory.util.ts index ffb9c4f51b6..76f1081d431 100644 --- a/packages/twenty-server/test/integration/metadata/suites/application/utils/sync-application-query-factory.util.ts +++ b/packages/twenty-server/test/integration/metadata/suites/application/utils/sync-application-query-factory.util.ts @@ -3,12 +3,14 @@ import { type Manifest } from 'twenty-shared/application'; export const syncApplicationQueryFactory = ({ manifest, + dryRun, }: { manifest: Manifest; + dryRun?: boolean; }) => ({ query: gql` - mutation SyncApplication($manifest: JSON!) { - syncApplication(manifest: $manifest) { + mutation SyncApplication($manifest: JSON!, $dryRun: Boolean) { + syncApplication(manifest: $manifest, dryRun: $dryRun) { applicationUniversalIdentifier actions } @@ -16,5 +18,6 @@ export const syncApplicationQueryFactory = ({ `, variables: { manifest, + dryRun, }, }); diff --git a/packages/twenty-server/test/integration/metadata/suites/application/utils/sync-application.util.ts b/packages/twenty-server/test/integration/metadata/suites/application/utils/sync-application.util.ts index 7a053e1d50b..d5f78b95ae5 100644 --- a/packages/twenty-server/test/integration/metadata/suites/application/utils/sync-application.util.ts +++ b/packages/twenty-server/test/integration/metadata/suites/application/utils/sync-application.util.ts @@ -14,15 +14,18 @@ export const syncApplication = async ({ manifest, expectToFail = false, token, + dryRun, }: { manifest: Manifest; expectToFail?: boolean; token?: string; + dryRun?: boolean; }): CommonResponseBody<{ syncApplication: WorkspaceMigration; }> => { const graphqlOperation = syncApplicationQueryFactory({ manifest, + dryRun, }); const response = await makeMetadataAPIRequest(graphqlOperation, token);