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