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