From 326850944d3adb22006da644c9e6fc77d372a822 Mon Sep 17 00:00:00 2001 From: martmull Date: Thu, 4 Jun 2026 22:32:40 +0200 Subject: [PATCH] feat(app-dev): surface metadata diff in dev sync and name failing migration actions Render the applied metadata changes (created/updated/deleted with their identifiers) in the dev sync output instead of only printing a generic 'Synced', and include the failing entity's universalIdentifier in WorkspaceMigrationRunnerException messages so metadata conflicts are diagnosable without reverse-engineering which object failed. --- .../format-sync-actions-summary.spec.ts | 97 ++++++++++++++++ ...sync-application-orchestrator-step.spec.ts | 85 ++++++++++++++ .../steps/format-sync-actions-summary.ts | 108 ++++++++++++++++++ .../sync-application-orchestrator-step.ts | 5 + ...rkspace-migration-runner.exception.spec.ts | 45 ++++++++ .../workspace-migration-runner.exception.ts | 16 ++- 6 files changed, 354 insertions(+), 2 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 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 00000000000..ffaee69b6b0 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/__tests__/format-sync-actions-summary.spec.ts @@ -0,0 +1,97 @@ +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 = Array.from({ length: 55 }, (_, index) => ({ + type: 'create', + metadataName: 'fieldMetadata', + 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', + }); + }); + + it('ignores entries that are not recognizable migration actions', () => { + const events = formatSyncActionsSummary([ + null, + 'not-an-action', + { type: 'unknown', metadataName: 'fieldMetadata' }, + ]); + + expect(events).toEqual([ + { message: 'No metadata changes', 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 00000000000..b039d2e4be7 --- /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 00000000000..109b2d8abce --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary.ts @@ -0,0 +1,108 @@ +import { type OrchestratorStateStepEvent } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state'; + +const MAX_DETAIL_LINES = 50; + +const VERB_BY_TYPE = { + create: 'created', + update: 'updated', + delete: 'deleted', +} as const; + +type ParsedSyncAction = { + type: keyof typeof VERB_BY_TYPE; + metadataName: string; + label: string; +}; + +const getStringProperty = ( + value: Record, + key: string, +): string | undefined => { + const property = value[key]; + + return typeof property === 'string' ? property : undefined; +}; + +const parseSyncAction = (action: unknown): ParsedSyncAction | null => { + if (typeof action !== 'object' || action === null) { + return null; + } + + const record = action as Record; + const type = record.type; + const metadataName = getStringProperty(record, 'metadataName'); + + if ( + (type !== 'create' && type !== 'update' && type !== 'delete') || + metadataName === undefined + ) { + return null; + } + + if (type === 'create') { + const flatEntity = + typeof record.flatEntity === 'object' && record.flatEntity !== null + ? (record.flatEntity as Record) + : {}; + + const label = + getStringProperty(flatEntity, 'name') ?? + getStringProperty(flatEntity, 'nameSingular') ?? + getStringProperty(flatEntity, 'universalIdentifier') ?? + 'unknown'; + + return { type, metadataName, label }; + } + + const label = getStringProperty(record, 'universalIdentifier') ?? 'unknown'; + + return { type, metadataName, label }; +}; + +export const formatSyncActionsSummary = ( + actions: unknown[], +): OrchestratorStateStepEvent[] => { + const parsedActions = actions + .map(parseSyncAction) + .filter((action): action is ParsedSyncAction => action !== null); + + if (parsedActions.length === 0) { + return [{ message: 'No metadata changes', status: 'info' }]; + } + + const counts = { create: 0, update: 0, delete: 0 }; + + for (const action of parsedActions) { + 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((part): part is string => part !== null); + + const events: OrchestratorStateStepEvent[] = [ + { message: `Metadata changes: ${summaryParts.join(', ')}`, status: 'info' }, + ]; + + const visibleActions = parsedActions.slice(0, MAX_DETAIL_LINES); + + for (const action of visibleActions) { + events.push({ + message: ` ${VERB_BY_TYPE[action.type]} ${action.metadataName} ${action.label}`, + status: 'info', + }); + } + + const hiddenCount = parsedActions.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 e24362e20a4..3a8bd798a1c 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,10 @@ export class SyncApplicationOrchestratorStep { const syncResult = await this.apiService.syncApplication(manifest); if (syncResult.success) { + const syncData = syncResult.data as { actions?: unknown[] } | undefined; + const actions = Array.isArray(syncData?.actions) ? syncData.actions : []; + + events.push(...formatSyncActionsSummary(actions)); events.push({ message: '✓ Synced', status: 'success' }); step.output = { syncStatus: 'synced', error: null }; step.status = 'done'; 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 00000000000..9f6b7cb491c --- /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 23333666598..13e1c5f665e 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 @@ -1,6 +1,6 @@ import { type MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; -import { assertUnreachable, CustomError } from 'twenty-shared/utils'; +import { assertUnreachable, CustomError, isDefined } from 'twenty-shared/utils'; import { type AllUniversalWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common'; @@ -34,6 +34,13 @@ export type WorkspaceMigrationRunnerExecutionErrors = { actionTranspilation?: Error; }; +const getActionUniversalIdentifier = ( + action: AllUniversalWorkspaceMigrationAction, +): string | undefined => + action.type === 'create' + ? action.flatEntity?.universalIdentifier + : action.universalIdentifier; + const { // oxlint-disable-next-line unused-imports/no-unused-vars EXECUTION_FAILED: WorkspaceMigrationRunnerExceptionExecutionFailedCode, @@ -62,8 +69,13 @@ export class WorkspaceMigrationRunnerException extends CustomError { constructor(args: WorkspaceMigrationRunnerExceptionConstructorArgs) { if (args.code === WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED) { + const universalIdentifier = getActionUniversalIdentifier(args.action); + const identifierClause = isDefined(universalIdentifier) + ? ` (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;