feat(app-dev): surface metadata diff in dev sync and name failing migration actions (#21249)
Split out of #21240. - Render the applied metadata changes (created/updated/deleted + identifiers) in the dev sync output instead of a bare `✓ Synced`. - Include the failing entity's `universalIdentifier` in `WorkspaceMigrationRunnerException` messages so conflicts are diagnosable. <img width="637" height="114" alt="image" src="https://github.com/user-attachments/assets/61422a16-370c-4e9b-a2f6-c29ce17f3b1b" /> <img width="497" height="104" alt="image" src="https://github.com/user-attachments/assets/d493c398-da29-49c9-ac5e-aa0f26cd7389" /> <img width="593" height="127" alt="image" src="https://github.com/user-attachments/assets/15e26edc-c0e4-4427-bd34-909040e970c9" /> --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { FileApi } from '@/cli/utilities/api/file-api';
|
||||
import { LogicFunctionApi } from '@/cli/utilities/api/logic-function-api';
|
||||
import { SchemaApi } from '@/cli/utilities/api/schema-api';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { type SyncAction } from 'twenty-shared/metadata';
|
||||
|
||||
type ApiServiceOptions = {
|
||||
disableInterceptors?: boolean;
|
||||
@@ -72,7 +73,12 @@ export class ApiService {
|
||||
return this.applicationApi.createDevelopmentApplication(...args);
|
||||
}
|
||||
|
||||
syncApplication(manifest: Manifest): Promise<ApiResponse> {
|
||||
syncApplication(manifest: Manifest): Promise<
|
||||
ApiResponse<{
|
||||
applicationUniversalIdentifier: string;
|
||||
actions: SyncAction[];
|
||||
}>
|
||||
> {
|
||||
return this.applicationApi.syncApplication(manifest);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
|
||||
import axios, { type AxiosInstance, type AxiosResponse } from 'axios';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { type SyncAction } from 'twenty-shared/metadata';
|
||||
|
||||
export class ApplicationApi {
|
||||
constructor(private readonly client: AxiosInstance) {}
|
||||
@@ -251,7 +252,12 @@ export class ApplicationApi {
|
||||
}
|
||||
}
|
||||
|
||||
async syncApplication(manifest: Manifest): Promise<ApiResponse> {
|
||||
async syncApplication(manifest: Manifest): Promise<
|
||||
ApiResponse<{
|
||||
applicationUniversalIdentifier: string;
|
||||
actions: SyncAction[];
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const mutation = `
|
||||
mutation SyncApplication($manifest: JSON!) {
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import { type SyncAction } from 'twenty-shared/metadata';
|
||||
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: SyncAction[] = Array.from({ length: 55 }, (_, index) => ({
|
||||
type: 'create' as const,
|
||||
metadataName: 'fieldMetadata' as const,
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+85
@@ -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');
|
||||
});
|
||||
});
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { type OrchestratorStateStepEvent } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { type SyncAction } from 'twenty-shared/metadata';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const MAX_DETAIL_LINES = 50;
|
||||
|
||||
const VERB_BY_TYPE = {
|
||||
create: 'created',
|
||||
update: 'updated',
|
||||
delete: 'deleted',
|
||||
} as const;
|
||||
|
||||
const getActionLabel = (action: SyncAction): string => {
|
||||
if (action.type === 'create') {
|
||||
return (
|
||||
action.flatEntity?.name ??
|
||||
action.flatEntity?.nameSingular ??
|
||||
action.flatEntity?.universalIdentifier ??
|
||||
'unknown'
|
||||
);
|
||||
}
|
||||
|
||||
return action.universalIdentifier;
|
||||
};
|
||||
|
||||
export const formatSyncActionsSummary = (
|
||||
actions: SyncAction[],
|
||||
): OrchestratorStateStepEvent[] => {
|
||||
if (actions.length === 0) {
|
||||
return [{ message: 'No metadata changes', status: 'info' }];
|
||||
}
|
||||
|
||||
const counts = { create: 0, update: 0, delete: 0 };
|
||||
|
||||
for (const action of actions) {
|
||||
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(isDefined);
|
||||
|
||||
const events: OrchestratorStateStepEvent[] = [
|
||||
{ message: `Metadata changes: ${summaryParts.join(', ')}`, status: 'info' },
|
||||
];
|
||||
|
||||
const visibleActions = actions.slice(0, MAX_DETAIL_LINES);
|
||||
|
||||
for (const action of visibleActions) {
|
||||
events.push({
|
||||
message: ` ${VERB_BY_TYPE[action.type]} ${action.metadataName} ${getActionLabel(action)}`,
|
||||
status: 'info',
|
||||
});
|
||||
}
|
||||
|
||||
const hiddenCount = actions.length - visibleActions.length;
|
||||
|
||||
if (hiddenCount > 0) {
|
||||
events.push({
|
||||
message: ` …and ${hiddenCount} more change(s)`,
|
||||
status: 'info',
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
};
|
||||
+4
@@ -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,9 @@ export class SyncApplicationOrchestratorStep {
|
||||
const syncResult = await this.apiService.syncApplication(manifest);
|
||||
|
||||
if (syncResult.success) {
|
||||
const syncData = syncResult.data;
|
||||
|
||||
events.push(...formatSyncActionsSummary(syncData.actions));
|
||||
events.push({ message: '✓ Synced', status: 'success' });
|
||||
step.output = { syncStatus: 'synced', error: null };
|
||||
step.status = 'done';
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
import { type SyncAction } from 'twenty-shared/metadata';
|
||||
|
||||
@ObjectType('WorkspaceMigration')
|
||||
export class WorkspaceMigrationDTO {
|
||||
@@ -8,5 +9,5 @@ export class WorkspaceMigrationDTO {
|
||||
applicationUniversalIdentifier: string;
|
||||
|
||||
@Field(() => GraphQLJSON)
|
||||
actions: unknown[];
|
||||
actions: SyncAction[];
|
||||
}
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ Report: {
|
||||
`;
|
||||
|
||||
exports[`formatUpgradeErrorForStorage should format a WorkspaceMigrationRunnerException with EXECUTION_FAILED 1`] = `
|
||||
"[WorkspaceMigrationRunnerException] Migration action 'create' for 'objectMetadata' failed
|
||||
"[WorkspaceMigrationRunnerException] Migration action 'create' for 'objectMetadata' (universalIdentifier: test-object-uid) failed
|
||||
Code: EXECUTION_FAILED
|
||||
Action: create on objectMetadata
|
||||
Metadata error: column "label" cannot be null
|
||||
|
||||
+1
@@ -57,6 +57,7 @@ describe('formatUpgradeErrorForStorage', () => {
|
||||
const action = {
|
||||
type: 'create',
|
||||
metadataName: 'objectMetadata',
|
||||
flatEntity: { universalIdentifier: 'test-object-uid' },
|
||||
} as unknown as AllUniversalWorkspaceMigrationAction;
|
||||
|
||||
const error = new WorkspaceMigrationRunnerException({
|
||||
|
||||
+45
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
+25
-1
@@ -34,6 +34,25 @@ export type WorkspaceMigrationRunnerExecutionErrors = {
|
||||
actionTranspilation?: Error;
|
||||
};
|
||||
|
||||
const getActionUniversalIdentifierOrThrow = (
|
||||
action: AllUniversalWorkspaceMigrationAction,
|
||||
): string => {
|
||||
if (action.type === 'create') {
|
||||
const universalIdentifier = action.flatEntity?.universalIdentifier;
|
||||
|
||||
if (!universalIdentifier) {
|
||||
throw new WorkspaceMigrationRunnerException({
|
||||
message: `Missing universalIdentifier on create action for '${action.metadataName}'`,
|
||||
code: WorkspaceMigrationRunnerExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
|
||||
return universalIdentifier;
|
||||
}
|
||||
|
||||
return action.universalIdentifier;
|
||||
};
|
||||
|
||||
const {
|
||||
// oxlint-disable-next-line unused-imports/no-unused-vars
|
||||
EXECUTION_FAILED: WorkspaceMigrationRunnerExceptionExecutionFailedCode,
|
||||
@@ -62,8 +81,13 @@ export class WorkspaceMigrationRunnerException extends CustomError {
|
||||
|
||||
constructor(args: WorkspaceMigrationRunnerExceptionConstructorArgs) {
|
||||
if (args.code === WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED) {
|
||||
const universalIdentifier = getActionUniversalIdentifierOrThrow(
|
||||
args.action,
|
||||
);
|
||||
const identifierClause = ` (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;
|
||||
|
||||
@@ -25,6 +25,7 @@ export type {
|
||||
MetadataValidationErrorResponse,
|
||||
} from './types/MetadataValidationError';
|
||||
export { WorkspaceMigrationV2ExceptionCode } from './types/MetadataValidationError';
|
||||
export type { SyncAction } from './types/sync-action.type';
|
||||
export { addCustomSuffixIfIsReserved } from './utils/add-custom-suffix-if-reserved.util';
|
||||
export { computeMetadataNameFromLabel } from './utils/compute-metadata-name-from-label.util';
|
||||
export { computeMetadataNamesFromLabelsOrThrow } from './utils/compute-metadata-names-from-labels-or-throw.util';
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { type AllMetadataName } from '@/metadata/types/all-metadata-name.type';
|
||||
|
||||
type SyncCreateAction = {
|
||||
type: 'create';
|
||||
metadataName: AllMetadataName;
|
||||
flatEntity?: {
|
||||
name?: string | null;
|
||||
nameSingular?: string | null;
|
||||
universalIdentifier?: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type SyncUpdateAction = {
|
||||
type: 'update';
|
||||
metadataName: AllMetadataName;
|
||||
universalIdentifier: string;
|
||||
};
|
||||
|
||||
type SyncDeleteAction = {
|
||||
type: 'delete';
|
||||
metadataName: AllMetadataName;
|
||||
universalIdentifier: string;
|
||||
};
|
||||
|
||||
export type SyncAction = SyncCreateAction | SyncUpdateAction | SyncDeleteAction;
|
||||
Reference in New Issue
Block a user