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.
This commit is contained in:
martmull
2026-06-05 13:00:43 +02:00
parent 6f9b59b224
commit 326850944d
6 changed files with 354 additions and 2 deletions
@@ -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' },
]);
});
});
@@ -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');
});
});
@@ -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<string, unknown>,
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<string, unknown>;
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<string, unknown>)
: {};
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;
};
@@ -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';
@@ -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",
);
});
});
@@ -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;