Back to top

Model Hub Approach

The Model Hub Approach delivers a powerful, model-first solution for enterprise-grade modeling applications. Built around a centralized Model Hub, it provides advanced capabilities including command-based editing, comprehensive state management with undo/redo functionality, and robust persistence — making it the ideal foundation for scenarios demanding precise control and model-oriented workflow.

Overview of the Model Hub

Core Features & Benefits

  • Command-based editing: Implement complex model operations through a structured command system, ensuring data integrity
  • Centralized state management: Maintain consistent model state with powerful undo/redo capabilities
  • Robust persistence layer: Deploy flexible storage strategies adaptable to your business requirements
  • Real-time multi-client support: Enable seamless collaboration with synchronized updates across all clients
  • Comprehensive model validation: Catch errors early with the built-in validation framework

When to Choose This Approach

The Model Hub approach delivers exceptional value for:

  • Modeling tools requiring granular control over model changes
  • Applications with complex editing operations needing comprehensive undo/redo capabilities
  • Collaborative solutions where multiple users must work simultaneously
  • Projects where structured model data (rather than plain text files) is your primary focus

ModelHub: The Central Intelligence for Model Management

The EMF Cloud Model Hub serves as the central coordination hub for model management, orchestrating multiple clients (like different editors) and their interactions with models. Beyond providing a generic model access API, the Model Hub is highly extensible to support various modeling languages.

Interacting with models

Applications can contain multiple model hubs, each with a unique context identifier. This allows for flexible organization based on application requirements.

Access the model hub for a specific context using the ModelHubProvider:

import { ModelHubProvider } from '@eclipse-emfcloud/model-service-theia/lib/node/model-hub-provider';
import { ModelHub } from '@eclipse-emfcloud/model-service';

@injectable()
class ModelHubExample {
  @inject(ModelHubProvider)
  modelHubProvider: ModelHubProvider

  modelHub: ModelHub;

  async initializeModelHub() {
    this.modelHub = await modelHubProvider('my-application-context');
  }
}
Loading and saving models

Load models using the model hub’s asynchronous API:

const modelId = 'file:///custom-editor/examples/workspace/my.custom';
const model: object = await modelHub.getModel(modelId);

With type safety:

const modelId = 'file:///custom-editor/examples/workspace/my.custom';
const model: CustomModelRoot = await modelHub.getModel<CustomModelRoot>(modelId);

Save models after changes:

// Using modelId as the commandStackId for single-model operations
const modelId = 'file:///custom-editor/examples/workspace/my.custom';
const commandStackId = modelId;
modelHub.save(commandStackId);

Or save all modified models:

modelHub.save();
Resolving references

ModelHub provides sophisticated handling of cross-references between model elements, representing them as structured information objects:

export interface NodeInfo {
  /** URI to the document containing the referenced element. */
  $documentUri: string;
  /** Navigation path inside the document */
  $path: string;
  /** `$type` property value */
  $type: string;
  /** Name of element */
  $name: string;
  /** Generic object properties */
  [x: string]: unknown;
}

export interface ReferenceError {
  $refText: string;
  $error: string;
}

export type ReferenceInfo = NodeReferenceInfo | ReferenceError;

On the client side, reference resolution is simplified with utility functions:

export type Reference<T> = Partial<NodeReferenceInfo> &
  Partial<ReferenceError> & {
    element(): Promise<T | undefined>;
    error(): string | undefined;
  };

export type ReferenceFactory<T> = (info: ReferenceInfo) => Reference<T>;

export function reviveReferences<T extends object>(obj: T, referenceFactory: ReferenceFactory<T>): T {
  for (const [key, value] of Object.entries(obj)) {
    if (isReferenceInfo(value)) {
      (obj as any)[key] = referenceFactory(value);
    } else if (value && typeof value === 'object') {
      reviveReferences(value, referenceFactory);
    }
  }
  return obj;
}
Changing models

Models are modified through specialized Model Services that implement domain-specific editing operations as Commands on a CommandStack:

export interface CustomModelService {
  getCustomModel(modelUri: string): Promise<CustomModelRoot | undefined>;

  unload(modelUri: string): Promise<void>;

  edit(modelUri: string, patch: Operation[]): Promise<PatchResult>;

  createNode(modelUri: string, parent: string | Workflow, args: CreateNodeArgs): Promise<PatchResult>;
}

export const CUSTOM_SERVICE_KEY = 'customModelService';

// Access and use the model service
const modelService: CustomModelService = modelHub.getModelService<CustomModelService>(CUSTOM_SERVICE_KEY);
await modelService.createNode(modelId, '/workflows/0', { type: 'AutomaticTask' });
Validating models

Model validation ensures data integrity through registered Validators:

const modelId = 'file:///custom-editor/examples/workspace/my.custom';
const diagnostic = await modelHub.validateModels(modelId);

Validate multiple models simultaneously:

const modelId1 = 'file:///custom-editor/examples/workspace/foo.custom';
const modelId2 = 'file:///custom-editor/examples/workspace/my.custom';
const diagnostic = await modelHub.validateModels(modelId1, modelId2);

Or validate all currently loaded models:

const diagnostic = await modelHub.validateModels();

For accessing the latest known validation results without triggering revalidation:

const modelId = 'file:///custom-editor/examples/workspace/my.custom';
const currentDiagnostic = modelHub.getValidationState(modelId);

Contributing modeling languages

Extend the ModelHub with custom modeling languages by implementing the ModelServiceContribution interface, which can handle:

  • Persistence (Save/Load)
  • Editing (Via Model Services and Commands)
  • Validation
  • Triggers

Here’s a minimal example:

export const CUSTOM_SERVICE_KEY = 'customModelService';

@injectable()
export class CustomModelServiceContribution extends AbstractModelServiceContribution {
  private modelService: CustomModelService;

  @postConstruct()
  protected init(): void {
    this.initialize({
      id: CUSTOM_SERVICE_KEY
    })
  }

  getModelService<S>(): S {
    if (! this.modelService){
      this.modelService = new CustomModelServiceImpl();
    }
    return this.modelService as unknown as S;
  }
}

A more comprehensive implementation with persistence capabilities:

export const CUSTOM_SERVICE_KEY = 'customModelService';

@injectable()
export class CustomModelServiceContribution extends AbstractModelServiceContribution {

  private modelService: CustomLanguageModelService;

  constructor(@inject(CustomLanguageModelService) private languageService: CustomLanguageModelService){
    // Empty constructor
  }

  @postConstruct()
  protected init(): void {
    this.initialize({
      id: CUSTOM_SERVICE_KEY,
      persistenceContribution: new CustomPersistenceContribution(this.languageService)
    });
  }

  getModelService<S>(): S {
    return this.modelService as unknown as S;
  }

  setModelManager(modelManager: ModelManager): void {
    super.setModelManager(modelManager);
    // Forward the model manager to our model service
    this.modelService = new CustomModelServiceImpl(modelManager, this.languageService);
  }
}

class CustomPersistenceContribution implements ModelPersistenceContribution {
  
  constructor(private languageService: CustomLanguageModelService) {
    // Empty
  }

  canHandle(modelId: string): Promise<boolean> {
    // Handle file URIs with '.custom' extension
    return Promise.resolve(modelId.startsWith('file:/') 
      && modelId.endsWith('.custom'));
  }

  async loadModel(modelId: string): Promise<object> {
    // Load model from file...
  }

  async saveModel(modelId: string, model: object): Promise<boolean> {
    // Save model to file...
  }
}
Persistence

Register a ModelPersistenceContribution to handle model loading and saving:

@postConstruct()
protected init(): void {
  this.initialize({
    id: CUSTOM_SERVICE_KEY,
    persistenceContribution: new CustomPersistenceContribution(this.languageService)
  });
}

The contribution must implement three key methods:

class CustomPersistenceContribution implements ModelPersistenceContribution {
  
  constructor(private languageService: CustomLanguageModelService) {
    // Empty
  }

  canHandle(modelId: string): Promise<boolean> {
    // Handle file URIs with '.custom' extension
    return Promise.resolve(modelId.startsWith('file:/') 
      && modelId.endsWith('.custom'));
  }

  async loadModel(modelId: string): Promise<object> {
    // Load model from file...
  }

  async saveModel(modelId: string, model: object): Promise<boolean> {
    // Save model to file...
  }
}
Editing Domain

Model editing is handled through the ModelManager, which is accessed via Model Services that execute Commands on CommandStacks:

Model Service implementation

The ModelService uses the ModelManager to execute Commands that modify models:

export class CustomModelServiceImpl implements CustomModelService {
  constructor(private modelManager: ModelManager<string>) {}

  async getCustomModel(modelUri: string): Promise<CustomModelRoot | undefined> {
    const key = getModelKey(modelUri);
    return this.modelManager.getModel<CustomModelRoot>(key);
  }

  async createNode(modelUri: string, parent: string | Workflow, args: CreateNodeArgs): Promise<PatchResult> {
    const model = await this.getCustomModel(modelUri);
    if (model === undefined) {
      return {
        success: false,
        error: `Failed to edit ${modelUri.toString()}: Model not found`
      };
    }

    // Resolve parent element
    const parentPath = this.getParentPath(model, parent);
    const parentElement = getValueByPointer(model, parentPath);
    if (!isWorkflow(parentElement)) {
      throw new Error(`Parent element is not a Workflow: ${parentPath}`);
    }

    // Create new node
    const newNode = createNode(args.type, parentElement, args);

    // Create JSON patch
    const patch: Operation[] = [
      {
        op: 'add',
        path: `${parentPath}/nodes/-`,
        value: newNode
      }
    ];
    
    // Get command stack and execute command
    const stackId = getStackId(modelUri);
    const stack = this.modelManager.getCommandStack(stackId);
    const command = new PatchCommand('Create Node', modelUri, patch);
    const result = await stack.execute(command);

    // Return result
    const patchResult = result?.get(command);
    if (patchResult === undefined) {
      return {
        success: false,
        error: `Failed to edit ${modelUri.toString()}: Model edition failed`
      };
    }
    return {
      success: true,
      patch: patchResult
    };
  }
}

Alternative approaches for creating patch commands include using JSON Patch libraries:

// Using fast-json-patch to generate a diff
const updatedModel = deepClone(model) as CustomModelRoot;
const updatedWorkflow = getValueByPointer(updatedModel, parentPath);
updatedWorkflow.nodes.push(newNode);
const patch: Operation[] = compare(model, updatedModel);
const command = new PatchCommand('Create Node', modelUri, patch);

Or using the built-in Model Updater for direct editing:

// Using Model Updater for direct modification
const command = new PatchCommand('Create Node', modelUri, model => {
  const workflow = getValueByPointer(model, parentPath);
  const newNode = createNode(args.type, parentElement, args);
  workflow.nodes.push(newNode);
});
Command Stack IDs

Command Stack IDs define the scope of undo/redo operations. Typical scenarios include:

  • One command stack per editor (for independent editing)
  • Shared command stacks for interrelated models (for consistent state)
Model Hub Context

A Context defines the scope of a Model Hub instance, with completely independent contributions, model managers, and command stacks. Applications can define contexts based on:

  • Project ID (for project isolation)
  • Language ID (for language isolation)
  • Or a single constant context for simpler applications
Validators

Register validators through a ValidationContribution:

@postConstruct()
protected init(): void {
  this.initialize({
    id: CUSTOM_SERVICE_KEY,
    validationContribution: new CustomValidationContribution(this.languageService)
  });
}

The ValidationContribution provides a list of Validators:

class CustomValidationContribution implements ModelValidationContribution {

  constructor(private languageService: CustomLanguageModelService){
    // Empty
  }

  getValidators(): Validator[] {
    return [
      new WorkflowValidator(),
      new TaskValidator()
    ];
  }
}

class WorkflowValidator implements Validator<string> {
  async validate(modelId: string, model: object): Promise<Diagnostic> {
    // Type guard to ensure this validator only handles relevant models
    if (isWorkflow(modelId, model)){
      // Validate workflow structure
    } else {
      return ok();
    }
  }
}

class TaskValidator implements Validator<string> {
  async validate(modelId: string, model: object): Promise<Diagnostic> {
    if (isWorkflow(modelId, model)){
      const tasks = model.nodes.filter(
        node => node.type === 'AutomaticTask' 
        || node.type === 'ManualTask');
      for (const task of tasks){
        // Validate each task
      }
    } else {
      return ok();
    }
  }
}
Custom APIs

Model Service Contributions expose public APIs for interaction with their models:

// Model Service definition
export interface CustomModelService {
  getCustomModel(modelUri: string): Promise<CustomModelRoot | undefined>;
  unload(modelUri: string): Promise<void>;
  edit(modelUri: string, patch: Operation[]): Promise<PatchResult>;
  createNode(modelUri: string, parent: string | Workflow, args: CreateNodeArgs): Promise<PatchResult>;
}

export const CUSTOM_SERVICE_KEY = 'customModelService';

The API can be accessed by any model hub client:

const customModelService = modelHub.getModelService<CustomModelService>(CUSTOM_SERVICE_KEY);
const modelUri = 'file:///custom-editor/examples/workspace/my.custom';
const customModel = await customModelService.getModel(modelUri);
const createArgs = {
  type: 'AutomaticTask',
  name: 'new task'
}
await customModelService.createNode(modelUri, customModel.workflows[0], createArgs);

Form Implementation

Overview of a Form with Model Hub

Create sophisticated form based editors in Theia by leveraging JSON Forms and ModelHub. This integration provides a robust foundation for creating customized form editors with seamless data management through the Frontend Model Hub.

Overview

The Theia Tree Editor framework provides extensive base classes and service definitions that can be extended and implemented to create tailored editors for specific data requirements. For comprehensive details, see the official Theia Tree Editor documentation.

Integrating ModelHub

Creating a Form Editor involves two key aspects:

  1. Data provisioning: Use the FrontendModelHub to fetch and manage models
  2. Change synchronization: Implement a ModelService to propagate editor changes back to the ModelHub

Achieve this integration by injecting the FrontendModelHub into your constructor and using it within the init method to monitor model changes and update the form accordingly. Implement the handleFormUpdate method to capture and apply data changes to the ModelHub-tracked model.

Example

Your widget should integrate the ModelHub by adding a listener within the init method:

this.modelHub
  .subscribe(this.getResourceUri()?.toString() ?? '')
  .then((subscription) => {
    subscription.onModelChanged = (_modelId, model, _delta) => {
      this.updateForm(model);
    };
  });

This listener ensures your form editor stays synchronized with any model changes in real-time.

Additionally, implement a handleFormUpdate method to propagate editor changes to the model:

protected override async handleFormUpdate(data: any): Promise<void> {
  const result = await this.modelService.edit(this.getResourceUri()?.toString() ?? '', data);
  this.updateForm(result.data);
}

This method ensures that any editor changes are immediately reflected in the model managed by the ModelHub.

The ModelService is a custom service with access to the ModelManager, see the Custom APIs section.

Diagram Editor Implementation

Overview of GLSP with Model Hub

Introduction

Create powerful, interactive diagram editors using the GLSP framework integrated with the ModelHub. This combination provides a seamless foundation for sophisticated diagramming tools with robust data management. The GLSP diagram editor leverages the ModelHub and ModelService interfaces for optimal performance and maintainability.

ModelHub Integration

SourceModelStorage

The source model storage handles persistence of source models — loading from and saving to the model state. A source model is the underlying data model from which the diagram’s graphical representation (graph model) is generated.

With ModelHub integration, these responsibilities are elegantly handled by the ModelHub itself, which provides:

  • Model loading
  • Subscription to model changes
  • Dirty state tracking
  • Model saving functionality
GModelFactory

The graph model factory transforms the source model (from the model state) into a graphical representation (GModelRoot). For complex transformations like creating edges, the CustomModelService provides reference resolution capabilities.

Model Operations

Operations on the model are forwarded to the CustomModelService, which provides specialized functions for model manipulation — such as dedicated create/delete functions that incorporate diagram-specific data like canvas positions.

Example

WorkflowModelStorage

The WorkflowModelStorage demonstrates how to load and save source models via the ModelHub:

Load the source model

Fetch the CustomModelRoot from the modelHub:

this.modelHub.getModel<CustomModelRoot>(modelUri);
Detailed implementation
  async loadSourceModel(action: RequestModelAction): Promise<void> {
    const modelUri = this.getUri(action);
    const customModel = await this.modelHub.getModel<CustomModelRoot>(modelUri);
    if (!customModel && customModel.workflows.length < 1)) {
      throw new GLSPServerError('Expected Model with at least one workflow');
    }
    this.modelState.setSemanticRoot(modelUri, customModel);
    this.subscribeToChanges(modelUri);
  }
Subscribe to model changes

Maintain real-time synchronization with model changes:

this.subscription = this.modelHub.subscribe(modelUri);
this.subscription.onModelChanged = async (modelId: string, newModel: object) => {
  // handle model update
}
Detailed implementation
private subscribeToChanges(modelUri: string): void {
    this.subscription = this.modelHub.subscribe(modelUri);
    this.subscription.onModelChanged = async (modelId: string, newModel: object) => {
      if (!newModel || newModel.workflows.length < 1) {
        throw new GLSPServerError('Expected Model with at least one workflow');
      }
      this.modelState.setSemanticRoot(modelId, newModel);
      const actions = await this.submissionHandler.submitModel();
      const dirtyStateAction = actions.find(action => action.kind === SetDirtyStateAction.KIND);
      if (dirtyStateAction && SetDirtyStateAction.is(dirtyStateAction)) {
        dirtyStateAction.isDirty = this.modelHub.isDirty(this.modelState.semanticUri);
      }
      this.actionDispatcher.dispatchAll(actions);
    };
  }
Save model

Trigger model saving through the ModelHub:

this.modelHub.save(modelUri)
Detailed implementation
async saveSourceModel(action: SaveModelAction): Promise<void> {
    const modelUri = action.fileUri ?? this.modelState.semanticUri;
    if (modelUri) {
      await this.modelHub.save(modelUri);
    }
  }
CustomGModelFactory

Resolve references using the CustomModelServer to properly translate the source model into a GModelRoot:

  @inject(CustomModelServer)
  protected modelServer: CustomModelServer;

  ...

  protected async createEdge(edge: Flow): Promise<GEdge> {
    const [sourceNode, targetNode] = await Promise.all([
      this.modelServer.resolve<Node>(edge.source as Required<NodeReferenceInfo>),
      this.modelServer.resolve<Node>(edge.target as Required<NodeReferenceInfo>)
    ]);
    return GEdge.builder()
      .id(edge.id)
      .sourceId(sourceNode.id)
      .targetId(targetNode.id)
      .build();
  }
Model Operations via the CustomModelService

Forward model operations to the CustomModelService for clean, maintainable code:

  override createCommand(operation: CreateNodeOperation): MaybePromise<Command | undefined> {
    return new CustomModelCommand(
      this.modelState,
      this.serializer,
      () => this.createWorkflowNode(operation)
    );
  }

  async createWorkflowNode(operation: CreateNodeOperation): Promise<void> {
    const container = this.modelState.semanticRoot;
    const modelService = this.modelHub.getModelService<CustomModelService>(CUSTOM_SERVICE_KEY);
    const modelId = this.modelState.semanticUri;
    await modelService?.createNode(modelId, container.workflows[0], {
      posX: this.getLocation(operation)?.x ?? Point.ORIGIN.x,
      posY: this.getLocation(operation)?.y ?? Point.ORIGIN.y,
      type: this.getNodeType(operation)
    });
  }

This approach works for all diagram editor operations including deleting elements, resizing, repositioning, and editing labels.

Example Implementation

Explore detailed examples of the Model Hub approach in action:

https://github.com/eclipse-emfcloud/modelhub/tree/main/examples/theia

Be aware that the example does not cover all cases described above.