@eclipse-emfcloud/model-service
Version:
Model service framework
512 lines (450 loc) • 17.1 kB
text/typescript
// *****************************************************************************
// Copyright (C) 2023-2024 STMicroelectronics.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: MIT License which is
// available at https://opensource.org/licenses/MIT.
//
// SPDX-License-Identifier: EPL-2.0 OR MIT
// *****************************************************************************
import { ModelAccessorBus } from '@eclipse-emfcloud/model-accessor-bus';
import { getLogger } from '@eclipse-emfcloud/model-logger';
import {
ChangeSubscription,
CommandStack,
CoreCommandStackSubscription,
DirtyStateChangedCallback,
ModelManager,
} from '@eclipse-emfcloud/model-manager';
import {
Diagnostic,
ModelValidationService,
merge,
} from '@eclipse-emfcloud/model-validation';
import { Operation } from 'fast-json-patch';
import { pull } from 'lodash';
import {
ModelHub,
ModelHubSubscription,
ModelPersistenceContribution,
ModelServiceContribution,
} from '../api';
import { HubAwareProvider } from './hub-aware-accessor-provider';
import { ModelManagerSubscription } from './model-service-model-manager';
import { ModelTriggerEngine } from './model-trigger-engine';
const logger = getLogger('model-service/model-hub-impl');
export class ModelHubImpl<K = string, C = unknown> implements ModelHub<K, C> {
private contributions = new Map<string, ModelServiceContribution<K>>();
private subscriptions: SubscriptionEntry<K>[] = [];
private selfSubscription: ModelHubSubscription<K>;
private modelManagerSubscription: ModelManagerSubscription<K>;
private liveValidationSubscription: ChangeSubscription<K> | undefined;
private dirtyStateSub: CoreCommandStackSubscription<K>;
// The core command stack tracks dirty state per editing context, but we track
// it per model ID, so we have to aggregate each editing context's state per model ID.
// Key is model ID; value is editing contexts (command stack IDs) in which it is dirty
private dirtyState = new Map<K, Set<string>>();
private pendingLoads = new Map<K, Promise<object>>();
private disposed = false;
constructor(
public context: C,
private modelManager: ModelManager<K>,
private validationService: ModelValidationService<K>,
private modelAccessorBus: ModelAccessorBus
) {
this.selfSubscription = this.subscribe();
this.selfSubscription.onModelLoaded = this.handleModelLoaded.bind(this);
this.liveValidation = true;
this.dirtyStateSub = modelManager
.getCommandStack('')
.getCoreCommandStack()
.subscribe();
this.dirtyStateSub.onDirtyStateChanged =
this.handleDirtyStateChanged.bind(this);
this.modelManagerSubscription = modelManager.subscribe();
this.modelManagerSubscription.onModelLoaded =
this.notifyModelLoaded.bind(this);
this.modelManagerSubscription.onModelUnloaded =
this.notifyModelUnloaded.bind(this);
}
dispose(): void {
this.disposed = true;
this.liveValidation = false;
this.selfSubscription.close();
this.modelManagerSubscription.close();
Array.from(this.contributions.values())
.filter(isDisposable)
.forEach((contrib) => safeCallback(contrib, contrib.dispose));
this.notifyDisposed();
this.contributions.clear();
this.subscriptions.forEach((sub) => sub.subscription.close());
this.subscriptions.length = 0;
}
get isDisposed(): boolean {
return this.disposed;
}
addModelServiceContribution<M extends object = object>(
modelServiceContribution: ModelServiceContribution<K, M>
): void {
const id = modelServiceContribution.id;
if (this.contributions.has(id)) {
throw new Error(
'A ModelServiceContribution is already registered for id: ' + id
);
}
this.contributions.set(id, modelServiceContribution);
modelServiceContribution.setModelManager(this.modelManager);
modelServiceContribution.setModelAccessorBus(this.modelAccessorBus);
const validators =
modelServiceContribution.validationContribution?.getValidators() ?? [];
validators.forEach((v) => this.validationService.addValidator(v));
modelServiceContribution.setValidationService(this.validationService);
const triggers =
modelServiceContribution.triggerContribution?.getTriggers() ?? [];
triggers.forEach((t) => this.triggerEngine?.addModelTrigger(t));
const providers =
modelServiceContribution.modelAccessorContribution?.getProviders() ?? [];
providers.forEach((provider) => {
if (typeof (provider as HubAwareProvider).setModelHub === 'function') {
(provider as HubAwareProvider<K, C>).setModelHub(this);
}
this.modelAccessorBus.register(provider);
});
// Do not set the contribution's hub yet as this is required to be done only
// after all contributions are added, configured, and available to one other
}
get liveValidation(): boolean {
return this.liveValidationSubscription !== undefined;
}
set liveValidation(liveValidation: boolean) {
if (liveValidation === this.liveValidation) {
return;
}
// We're toggling live validation state
if (this.liveValidationSubscription) {
// Turn off live validation: close the subscription
this.liveValidationSubscription.close();
this.liveValidationSubscription = undefined;
} else {
// Turn on live validation: create the subscription
this.liveValidationSubscription = this.modelManager.subscribe();
this.liveValidationSubscription.onModelChanged = (modelId: K) =>
this.validateModels(modelId);
}
}
private get triggerEngine(): ModelTriggerEngine<K> | undefined {
return 'triggerEngine' in this.modelManager &&
this.modelManager.triggerEngine instanceof ModelTriggerEngine
? this.modelManager.triggerEngine
: undefined;
}
getModelService<S = unknown>(id: string): S | undefined {
const contribution = this.contributions.get(id);
return contribution?.getModelService();
}
getModelAccessorBus(): ModelAccessorBus {
return this.modelAccessorBus;
}
async getModel<M extends object = object>(modelId: K): Promise<M> {
// Check if model is already loaded
const existingModel = this.modelManager.getModel<M>(modelId) as M;
if (existingModel !== undefined) {
return existingModel;
}
return this.loadModel(modelId);
}
// Find the persistence contribution responsible for loading
// the requested model and load it. If a load of the same
// model is already pending, return that pending promise.
private loadModel<M extends object>(modelId: K): Promise<M> {
let result = this.pendingLoads.get(modelId) as Promise<M>;
if (!result) {
result = this.getPersistenceContribution<M>(modelId).then((contrib) =>
contrib.loadModel(modelId)
);
this.pendingLoads.set(modelId, result);
result
.then((loadedModel) => {
this.modelManager.setModel(modelId, loadedModel);
})
.finally(() => this.pendingLoads.delete(modelId));
}
return result;
}
subscribe<M extends object = object>(
...modelIds: K[]
): ModelHubSubscription<K, M> {
const subscription = <ModelHubSubscription<K, M>>{};
const onModelChanged = (modelId: K, model: M, delta?: Operation[]) =>
subscription.onModelChanged?.(modelId, model, delta);
const onValidationChanged = (
modelId: K,
model: M,
diagnostic: Diagnostic
) => subscription.onModelValidated?.(modelId, model, diagnostic);
const entry: SubscriptionEntry<K> = {
modelIds: modelIds.length ? new Set(modelIds) : undefined,
subscription,
};
this.subscriptions.push(entry);
if (modelIds.length === 0) {
// Easy case: universal subscription
const modelSubscription = this.modelManager.subscribe<M>();
modelSubscription.onModelChanged = onModelChanged;
const validationSubscription = this.validationService.subscribe<M>();
validationSubscription.onValidationChanged = onValidationChanged;
subscription.close = () => {
modelSubscription.close();
validationSubscription.close();
pull(this.subscriptions, entry);
};
} else {
// Subscription to particular models
const modelSubs = modelIds.map((id) =>
this.modelManager.subscribe<M>(id)
);
modelSubs.forEach((sub) => (sub.onModelChanged = onModelChanged));
const validationSubs = modelIds.map((id) =>
this.validationService.subscribe<M>(id)
);
validationSubs.forEach(
(sub) => (sub.onValidationChanged = onValidationChanged)
);
subscription.close = () => {
modelSubs.forEach((sub) => sub.close());
validationSubs.forEach((sub) => sub.close());
pull(this.subscriptions, entry);
};
}
return subscription;
}
async save(...commandStackIds: string[]): Promise<boolean> {
let result = false;
const allCommandStackIds = commandStackIds.length
? commandStackIds
: this.modelManager.getCommandStackIds();
for (const commandStackId of allCommandStackIds) {
const commandStack = this.getCommandStack(commandStackId);
const modelIdsToSave = commandStack.getDirtyModelIds();
for (const modelId of modelIdsToSave) {
const persistenceContribution = await this.getPersistenceContribution(
modelId
);
const model = await this.getModel(modelId);
const saved = await persistenceContribution.saveModel(modelId, model);
result ||= saved;
}
commandStack.markSaved();
}
return result;
}
async validateModels(...modelIds: K[]): Promise<Diagnostic> {
const allDiagnostics: Promise<Diagnostic>[] = [];
const allIds = modelIds.length ? modelIds : this.modelManager.getModelIds();
for (const modelId of allIds) {
allDiagnostics.push(
this.getModel(modelId)
.then((model) => this.validationService.validate(modelId, model))
.catch((error) => ({
severity: 'error',
source: '@eclipse-emfcloud/model-service',
path: '',
message: `Failed to load model '${modelId}'.`,
data: error,
}))
);
}
return merge(...(await Promise.all(allDiagnostics)));
}
getValidationState(...modelIds: K[]): Diagnostic | undefined {
const allDiagnostics: Diagnostic[] = [];
const allIds = modelIds.length ? modelIds : this.modelManager.getModelIds();
for (const modelId of allIds) {
const modelDiagnostic =
this.validationService.getValidationState(modelId);
if (modelDiagnostic) {
allDiagnostics.push(modelDiagnostic);
}
}
return allDiagnostics.length ? merge(...allDiagnostics) : undefined;
}
protected getCommandStack(commandStackId: string): CommandStack<K> {
return this.modelManager.getCommandStack(commandStackId);
}
async undo(commandStackId: string): Promise<boolean> {
const commandStack = this.getCommandStack(commandStackId);
if (await commandStack.canUndo()) {
const deltas = await commandStack.undo();
return (deltas?.size ?? 0) > 0;
}
return false;
}
async redo(commandStackId: string): Promise<boolean> {
const commandStack = this.getCommandStack(commandStackId);
if (await commandStack.canRedo()) {
const deltas = await commandStack.redo();
return (deltas?.size ?? 0) > 0;
}
return false;
}
flush(commandStackId: string): boolean {
const commandStack = this.getCommandStack(commandStackId);
return commandStack.flush().length > 0;
}
isDirty(commandStackId: string): boolean {
const commandStack = this.getCommandStack(commandStackId);
return commandStack.isDirty();
}
protected handleDirtyStateChanged(
...args: Parameters<DirtyStateChangedCallback<K>>
): void {
const editingContext = args[0];
const modelDirtyState = args[1];
// Function to snapshot the currently dirty model IDs
const computeDirtyModelIds = () =>
new Set(
Array.from(this.dirtyState.entries())
.filter(([_, dirtyIn]) => dirtyIn.size > 0)
.map(([modelId]) => modelId)
);
const oldDirtyModelIds = computeDirtyModelIds();
// Update my aggregate per-model dirty state
for (const [modelId, isDirty] of modelDirtyState.entries()) {
const dirtyState = this.dirtyState.get(modelId) ?? new Set();
this.dirtyState.set(modelId, dirtyState);
if (isDirty) {
dirtyState.add(editingContext);
} else {
dirtyState.delete(editingContext);
}
}
const newDirtyModelIds = computeDirtyModelIds();
// Compute the symmetric difference
Array.from(oldDirtyModelIds).forEach((modelId) => {
if (newDirtyModelIds.delete(modelId)) {
oldDirtyModelIds.delete(modelId);
}
});
const notify = async (isDirty: boolean, modelIds: Set<K>) => {
modelIds.forEach((modelId) => {
this.subscriptions
.filter((sub) => !sub.modelIds || sub.modelIds.has(modelId))
.forEach(async (sub) => {
const model = await this.getModel(modelId);
if (sub.subscription.onModelDirtyState && model) {
safeCallback(
undefined,
sub.subscription.onModelDirtyState,
modelId,
model,
isDirty
);
}
});
});
};
notify(false, oldDirtyModelIds);
notify(true, newDirtyModelIds);
}
private async getPersistenceContribution<M extends object = object>(
modelId: K
): Promise<ModelPersistenceContribution<K, M>> {
// Find a contribution able to handle the requested model
for (const contribution of this.contributions.values()) {
if (await contribution.persistenceContribution.canHandle(modelId)) {
const result = contribution.persistenceContribution;
// This cast makes sense because the contribution averred that it can handle the model
return result as ModelPersistenceContribution<K, M>;
}
}
throw new Error(
'Failed to find a model persistence contribution for ModelID: ' + modelId
);
}
/**
* Handle the self-subscription notification of the loading of the
* identified model. This default implementation validates the newly
* loaded model if {@link liveValidation} is on.
*/
protected handleModelLoaded(modelId: K): void {
if (this.liveValidation) {
this.validateModels(modelId);
}
}
/**
* Invoke the model-loaded call-back of subscribers for the given
* model ID that have the call-back.
*/
private notifyModelLoaded(modelId: K): void {
this.subscriptions
.filter((sub) => !sub.modelIds || sub.modelIds.has(modelId))
.forEach((sub) => {
if (sub.subscription.onModelLoaded) {
safeCallback(undefined, sub.subscription.onModelLoaded, modelId);
}
});
}
/**
* Invoke the model-unloaded call-back of subscribers for the given
* model ID that have the call-back.
*/
private notifyModelUnloaded(modelId: K, model: object): void {
this.subscriptions
.filter((sub) => !sub.modelIds || sub.modelIds.has(modelId))
.forEach((sub) => {
if (sub.subscription.onModelUnloaded) {
safeCallback(
undefined,
sub.subscription.onModelUnloaded,
modelId,
model
);
}
});
}
/**
* Invoke the disposed call-back of subscribers that have the call-back.
*/
private notifyDisposed(): void {
this.subscriptions.forEach((sub) => {
if (sub.subscription.onModelHubDisposed) {
safeCallback(undefined, sub.subscription.onModelHubDisposed);
}
});
}
}
/**
* Record of a Model Hub subscription.
*/
interface SubscriptionEntry<K> {
/** The model IDs, if any, that the subscription targets. */
modelIds?: Set<K>;
/** The subscription. */
subscription: ModelHubSubscription<K>;
}
/**
* Safely invoke a call-back, reporting any uncaught exception that it
* may throw, to ensure that subsequent subscriptions don't miss out.
*/
const safeCallback = <F extends (this: unknown, ...args: unknown[]) => void>(
thisArg: unknown,
callback: F,
...args: Parameters<F>
): void => {
try {
callback.call(thisArg, ...args);
} catch (error) {
logger.error('Uncaught exception in ModelHub call-back.', error);
}
};
/** Type predicate for a disposable object. */
const isDisposable = <T extends object>(
target: T
): target is T & { dispose(): void } => {
return 'dispose' in target && typeof target.dispose === 'function';
};