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.

<img width="646" height="179" alt="image"
src="https://github.com/user-attachments/assets/59f3ddcd-2a5b-4b8a-b21a-c659abe16af0"
/>
This commit is contained in:
martmull
2026-06-05 19:49:02 +02:00
committed by GitHub
parent bfb83e93b2
commit 6c65d26ced
19 changed files with 257 additions and 51 deletions

View File

@@ -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!

View File

@@ -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']} })

View File

@@ -8761,6 +8761,9 @@ export default {
"manifest": [
15,
"JSON!"
],
"dryRun": [
6
]
}
],

View File

@@ -3315,6 +3315,7 @@ export type MutationStopAgentChatStreamArgs = {
export type MutationSyncApplicationArgs = {
dryRun?: InputMaybe<Scalars['Boolean']>;
manifest: Scalars['JSON'];
};

View File

@@ -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'})`,

View File

@@ -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 <ms>', 'Debounce in ms (default: 2 000)')
.option('-v, --verbose', 'Show detailed logs')
.option('-d, --debug', 'Show detailed logs (alias for --verbose)')

View File

@@ -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<CommandResult<AppDevOnceResult>> => {
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 {

View File

@@ -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<ApiResponse> {

View File

@@ -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',

View File

@@ -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([
{

View File

@@ -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({

View File

@@ -133,7 +133,7 @@ export class ApplicationDevelopmentResolver {
@Mutation(() => WorkspaceMigrationDTO)
async syncApplication(
@Args() { manifest }: ApplicationInput,
@Args() { manifest, dryRun }: ApplicationInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<WorkspaceMigrationDTO> {
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}`,

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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<WorkspaceMigration> {
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.',

View File

@@ -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,

View File

@@ -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<Pick<Manifest, 'objects' | 'fields'>>,
) => buildBaseManifest({ appId: TEST_APP_ID, roleId: TEST_ROLE_ID, overrides });
const findCustomObjectNames = async (): Promise<string[]> => {
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);
});

View File

@@ -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,
},
});

View File

@@ -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);