UNPKG

@eclipse-emfcloud/model-service

Version:
512 lines (450 loc) 17.1 kB
// ***************************************************************************** // 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'; };