@finos/legend-application-studio
Version:
Legend Studio application core
542 lines • 28.4 kB
JavaScript
/**
* Copyright (c) 2020-present, Goldman Sachs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { action, makeObservable, flowResult, flow, observable, computed, } from 'mobx';
import { LogEvent, assertErrorThrown, downloadFileUsingDataURI, guaranteeNonNullable, ContentType, NetworkClientError, HttpStatus, deleteEntry, assertTrue, readFileAsText, ActionState, formatDate, } from '@finos/legend-shared';
import { DEFAULT_TAB_SIZE, ActionAlertType, ActionAlertActionType, DEFAULT_DATE_TIME_FORMAT, } from '@finos/legend-application';
import { EntityDiffViewState } from '../editor-state/entity-diff-editor-state/EntityDiffViewState.js';
import { SPECIAL_REVISION_ALIAS } from '../editor-state/entity-diff-editor-state/EntityDiffEditorState.js';
import { EntityDiff, EntityChange, Revision, EntityChangeType, } from '@finos/legend-server-sdlc';
import { LEGEND_STUDIO_APP_EVENT } from '../../../__lib__/LegendStudioEvent.js';
import { WorkspaceSyncState } from './WorkspaceSyncState.js';
import { ACTIVITY_MODE } from '../EditorConfig.js';
import { EntityChangeConflictEditorState } from '../editor-state/entity-diff-editor-state/EntityChangeConflictEditorState.js';
class PatchLoaderState {
editorStore;
sdlcState;
changes;
currentChanges = [];
isLoadingChanges = false;
showModal = false;
isValidPatch = false;
constructor(editorStore, sdlcState) {
makeObservable(this, {
changes: observable,
currentChanges: observable,
isLoadingChanges: observable,
showModal: observable,
isValidPatch: observable,
overiddingChanges: computed,
openModal: action,
closeModal: action,
setIsValidPatch: action,
setPatchChanges: action,
deleteChange: action,
loadPatchFile: flow,
applyChanges: flow,
});
this.editorStore = editorStore;
this.sdlcState = sdlcState;
}
get overiddingChanges() {
if (this.changes?.length) {
return this.changes.filter((change) => this.currentChanges.find((local) => local.entityPath === change.entityPath));
}
return [];
}
openModal(localChanges) {
this.currentChanges = localChanges;
this.showModal = true;
}
closeModal() {
this.currentChanges = [];
this.setPatchChanges(undefined);
this.showModal = false;
}
setIsValidPatch(val) {
this.isValidPatch = val;
}
setPatchChanges(changes) {
this.changes = changes;
}
deleteChange(change) {
if (this.changes) {
deleteEntry(this.changes, change);
}
}
*loadPatchFile(file) {
try {
this.setPatchChanges(undefined);
assertTrue(file.type === ContentType.APPLICATION_JSON, `Patch file expected to be of type 'JSON'`);
const fileText = (yield readFileAsText(file));
const entityChanges = JSON.parse(fileText);
const changes = entityChanges.entityChanges.map((e) => EntityChange.serialization.fromJson(e));
this.setPatchChanges(changes);
this.setIsValidPatch(true);
}
catch (error) {
assertErrorThrown(error);
this.setIsValidPatch(false);
this.editorStore.applicationStore.notificationService.notifyError(`Can't load patch: Error: ${error.message}`);
}
}
*applyChanges() {
if (this.changes?.length) {
try {
const changes = this.changes;
this.closeModal();
yield flowResult(this.editorStore.graphState.loadEntityChangesToGraph(changes, undefined));
}
catch (error) {
assertErrorThrown(error);
this.editorStore.applicationStore.notificationService.notifyError(`Can't apply patch changes: Error: ${error.message}`);
}
}
}
}
export class LocalChangesState {
editorStore;
sdlcState;
workspaceSyncState;
pushChangesState = ActionState.create();
refreshLocalChangesDetectorState = ActionState.create();
refreshWorkspaceSyncStatusState = ActionState.create();
patchLoaderState;
constructor(editorStore, sdlcState) {
makeObservable(this, {
hasUnpushedChanges: computed,
refreshWorkspaceSyncStatus: flow,
refreshLocalChanges: flow,
pushLocalChanges: flow,
processConflicts: flow,
restartChangeDetection: flow,
});
this.editorStore = editorStore;
this.sdlcState = sdlcState;
this.patchLoaderState = new PatchLoaderState(editorStore, sdlcState);
this.workspaceSyncState = new WorkspaceSyncState(editorStore, sdlcState);
}
get hasUnpushedChanges() {
return Boolean(this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState
.changes.length);
}
downloadLocalChanges() {
const fileName = `entityChanges_(${this.sdlcState.currentProject?.name}_${this.sdlcState.activeWorkspace.workspaceId})_${formatDate(new Date(Date.now()), DEFAULT_DATE_TIME_FORMAT)}.json`;
const content = JSON.stringify({
message: '', // TODO?
entityChanges: this.computeLocalEntityChanges(),
revisionId: this.sdlcState.activeRevision.id,
}, undefined, DEFAULT_TAB_SIZE);
downloadFileUsingDataURI(fileName, content, ContentType.APPLICATION_JSON);
}
alertUnsavedChanges(onProceed) {
if (this.hasUnpushedChanges) {
this.editorStore.applicationStore.alertService.setActionAlertInfo({
message: 'Unsaved changes will be lost if you continue. Do you still want to proceed?',
type: ActionAlertType.CAUTION,
actions: [
{
label: 'Proceed',
type: ActionAlertActionType.PROCEED_WITH_CAUTION,
handler: () => onProceed(),
},
{
label: 'Abort',
type: ActionAlertActionType.PROCEED,
default: true,
},
],
});
}
else {
onProceed();
}
}
*refreshWorkspaceSyncStatus() {
try {
this.refreshWorkspaceSyncStatusState.inProgress();
const currentRemoteRevision = this.sdlcState.activeRemoteWorkspaceRevision;
yield flowResult(this.sdlcState.fetchRemoteWorkspaceRevision(this.sdlcState.activeProject.projectId, this.sdlcState.activeWorkspace));
if (currentRemoteRevision.id !==
this.sdlcState.activeRemoteWorkspaceRevision.id) {
if (this.sdlcState.isWorkspaceOutOfSync) {
this.workspaceSyncState.fetchIncomingRevisions();
const remoteWorkspaceEntities = (yield this.editorStore.sdlcServerClient.getEntitiesByRevision(this.sdlcState.activeProject.projectId, this.sdlcState.activeWorkspace, this.sdlcState.activeRemoteWorkspaceRevision.id));
this.editorStore.changeDetectionState.workspaceRemoteLatestRevisionState.setEntities(remoteWorkspaceEntities);
yield flowResult(this.editorStore.changeDetectionState.workspaceRemoteLatestRevisionState.buildEntityHashesIndex(remoteWorkspaceEntities, LogEvent.create(LEGEND_STUDIO_APP_EVENT.CHANGE_DETECTION_BUILD_LOCAL_HASHES_INDEX__SUCCESS)));
yield flowResult(this.editorStore.changeDetectionState.computeAggregatedWorkspaceRemoteChanges());
}
else {
this.editorStore.changeDetectionState.workspaceRemoteLatestRevisionState.setEntities([]);
this.editorStore.changeDetectionState.setPotentialWorkspacePullConflicts([]);
this.editorStore.changeDetectionState.setAggregatedWorkspaceRemoteChanges([]);
}
}
}
catch (error) {
assertErrorThrown(error);
this.editorStore.applicationStore.logService.error(LogEvent.create(LEGEND_STUDIO_APP_EVENT.SDLC_MANAGER_FAILURE), error);
}
finally {
this.refreshWorkspaceSyncStatusState.complete();
}
}
*pushLocalChanges(pushMessage) {
if (this.pushChangesState.isInProgress ||
this.editorStore.workspaceUpdaterState.isUpdatingWorkspace) {
return;
}
// check if the workspace is in conflict resolution mode
yield flowResult(this.processConflicts());
this.pushChangesState.inProgress();
const startTime = Date.now();
const localChanges = this.computeLocalEntityChanges();
if (!localChanges.length) {
this.pushChangesState.complete();
return;
}
yield flowResult(this.sdlcState.fetchRemoteWorkspaceRevision(this.sdlcState.activeProject.projectId, this.sdlcState.activeWorkspace));
if (this.sdlcState.isWorkspaceOutOfSync) {
// ensure changes/conflicts have been computed for latest remote version
const remoteWorkspaceEntities = (yield this.editorStore.sdlcServerClient.getEntitiesByRevision(this.sdlcState.activeProject.projectId, this.sdlcState.activeWorkspace, this.sdlcState.activeRemoteWorkspaceRevision.id));
this.editorStore.changeDetectionState.workspaceRemoteLatestRevisionState.setEntities(remoteWorkspaceEntities);
yield flowResult(this.editorStore.changeDetectionState.workspaceRemoteLatestRevisionState.buildEntityHashesIndex(remoteWorkspaceEntities, LogEvent.create(LEGEND_STUDIO_APP_EVENT.CHANGE_DETECTION_BUILD_LOCAL_HASHES_INDEX__SUCCESS)));
yield flowResult(this.editorStore.changeDetectionState.computeAggregatedWorkspaceRemoteChanges());
this.editorStore.applicationStore.alertService.setActionAlertInfo({
message: 'Local workspace is out-of-sync',
prompt: 'Please pull remote changes before pushing your local changes',
type: ActionAlertType.CAUTION,
actions: [
{
label: 'Pull remote changes',
type: ActionAlertActionType.STANDARD,
default: true,
handler: () => {
this.editorStore.setActiveActivity(ACTIVITY_MODE.LOCAL_CHANGES);
flowResult(this.workspaceSyncState.pullChanges()).catch(this.editorStore.applicationStore.alertUnhandledError);
},
},
{
label: 'Cancel',
type: ActionAlertActionType.PROCEED_WITH_CAUTION,
},
],
});
this.pushChangesState.complete();
return;
}
const currentHashesIndex = this.getCurrentHashIndexes();
try {
const nullableRevisionChange = (yield this.editorStore.sdlcServerClient.performEntityChanges(this.sdlcState.activeProject.projectId, this.sdlcState.activeWorkspace, {
message: pushMessage ??
`pushed new changes from ${this.editorStore.applicationStore.config.appName} [potentially affected ${localChanges.length === 1
? '1 entity'
: `${localChanges.length} entities`}]`,
entityChanges: localChanges,
revisionId: this.sdlcState.activeRevision.id,
}));
const revisionChange = guaranteeNonNullable(nullableRevisionChange, `Can't push an empty change set. This may be due to an error with change detection`);
const latestRevision = Revision.serialization.fromJson(revisionChange);
this.sdlcState.setCurrentRevision(latestRevision); // update current revision to the latest
this.sdlcState.setWorkspaceLatestRevision(latestRevision);
const syncFinishedTime = Date.now();
this.editorStore.applicationStore.logService.info(LogEvent.create(LEGEND_STUDIO_APP_EVENT.PUSH_LOCAL_CHANGES__SUCCESS), syncFinishedTime - startTime, 'ms');
// ======= (RE)START CHANGE DETECTION =======
this.stopChangeDetection();
try {
/**
* Here we try to rebuild local hash index. If failed, we will use local hash index, but for veracity, it's best to use entities
* coming from the server.
*/
const entities = (yield this.editorStore.sdlcServerClient.getEntitiesByRevision(this.sdlcState.activeProject.projectId, this.sdlcState.activeWorkspace, latestRevision.id));
this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState.setEntities(entities);
yield flowResult(this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState.buildEntityHashesIndex(entities, LogEvent.create(LEGEND_STUDIO_APP_EVENT.CHANGE_DETECTION_BUILD_LOCAL_HASHES_INDEX__SUCCESS)));
this.editorStore.tabManagerState.refreshCurrentEntityDiffViewer();
}
catch (error) {
assertErrorThrown(error);
/**
* NOTE: there is a known problem with the SDLC server where if we try to fetch the entities right after syncing, there is a chance
* that we get entities from the older commit (i.e. potentially some caching issue). As such, to account for this case, we will
* not try to get entities for the workspace HEAD, but for the revision returned from the syncing call (i.e. this must be the latest revision)
* if we get a 404, we will do a refresh and warn user about this. Otherwise, if we get other types of error, we will assume this is a network
* failure and use local workspace hashes index
*/
if (error instanceof NetworkClientError) {
if (error.response.status === HttpStatus.NOT_FOUND) {
this.editorStore.applicationStore.logService.error(LogEvent.create(LEGEND_STUDIO_APP_EVENT.SDLC_MANAGER_FAILURE), `Can't fetch entities for the latest workspace revision immediately after syncing`, error);
}
this.editorStore.applicationStore.alertService.setActionAlertInfo({
message: `Change detection engine failed to build hashes index for workspace after syncing`,
prompt: 'To fix this, you can either try to keep refreshing local changes until success or trust and reuse current workspace hashes index',
type: ActionAlertType.CAUTION,
actions: [
{
label: 'Use local hashes index',
type: ActionAlertActionType.PROCEED_WITH_CAUTION,
handler: () => {
this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState.setEntityHashesIndex(currentHashesIndex);
this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState.setIsBuildingEntityHashesIndex(false);
},
},
{
label: 'Refresh changes',
type: ActionAlertActionType.STANDARD,
default: true,
handler: this.editorStore.applicationStore.guardUnhandledError(() => flowResult(this.refreshLocalChanges())),
},
],
});
}
else {
throw error;
}
}
yield flowResult(this.restartChangeDetection());
this.editorStore.applicationStore.logService.info(LogEvent.create(LEGEND_STUDIO_APP_EVENT.CHANGE_DETECTION_RESTART__SUCCESS), Date.now() - syncFinishedTime, 'ms');
// ======= FINISHED (RE)START CHANGE DETECTION =======
}
catch (error) {
assertErrorThrown(error);
this.editorStore.applicationStore.logService.error(LogEvent.create(LEGEND_STUDIO_APP_EVENT.SDLC_MANAGER_FAILURE), error);
if (error instanceof NetworkClientError &&
error.response.status === HttpStatus.CONFLICT) {
// NOTE: a confict here indicates that the reference revision ID sent along with update call
// does not match the HEAD of the workspace, therefore, we need to prompt user to refresh the application
this.editorStore.applicationStore.notificationService.notifyWarning('Syncing failed. Current workspace revision is not the latest. Please backup your work and refresh the application');
// TODO: maybe we should do more here, e.g. prompt the user to download the patch, but that is for later
}
else {
this.editorStore.applicationStore.notificationService.notifyError(error);
}
}
finally {
this.pushChangesState.complete();
}
}
}
export class FormLocalChangesState extends LocalChangesState {
constructor(editorStore, sdlcState) {
super(editorStore, sdlcState);
makeObservable(this, {
openPotentialWorkspacePullConflict: action,
});
}
openLocalChange(diff) {
const fromEntityGetter = (entityPath) => {
if (entityPath) {
return this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState.entities.find((e) => e.path === entityPath);
}
return undefined;
};
const toEntityGetter = (entityPath) => {
if (!entityPath) {
return undefined;
}
const element = this.editorStore.graphManagerState.graph.getNullableElement(entityPath);
if (!element) {
return undefined;
}
const entity = this.editorStore.graphManagerState.graphManager.elementToEntity(element, {
pruneSourceInformation: true,
});
return entity;
};
const fromEntity = EntityDiff.shouldOldEntityExist(diff)
? guaranteeNonNullable(fromEntityGetter(diff.getValidatedOldPath()), `Can't find entity with path '${diff.oldPath}'`)
: undefined;
const toEntity = EntityDiff.shouldNewEntityExist(diff)
? guaranteeNonNullable(toEntityGetter(diff.getValidatedNewPath()), `Can't find entity with path '${diff.newPath}'`)
: undefined;
this.editorStore.tabManagerState.openTab(new EntityDiffViewState(this.editorStore, SPECIAL_REVISION_ALIAS.WORKSPACE_HEAD, SPECIAL_REVISION_ALIAS.LOCAL, diff.oldPath, diff.newPath, fromEntity, toEntity, fromEntityGetter, toEntityGetter));
}
openWorkspacePullChange(diff) {
const fromEntityGetter = (entityPath) => {
if (entityPath) {
return this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState.entities.find((e) => e.path === entityPath);
}
return undefined;
};
const toEntityGetter = (entityPath) => {
if (entityPath) {
return this.editorStore.changeDetectionState.workspaceRemoteLatestRevisionState.entities.find((e) => e.path === entityPath);
}
return undefined;
};
const fromEntity = EntityDiff.shouldOldEntityExist(diff)
? guaranteeNonNullable(fromEntityGetter(diff.getValidatedOldPath()), `Can't find entity with path '${diff.oldPath}'`)
: undefined;
const toEntity = EntityDiff.shouldNewEntityExist(diff)
? guaranteeNonNullable(toEntityGetter(diff.getValidatedNewPath()), `Can't find entity with path '${diff.newPath}'`)
: undefined;
this.editorStore.tabManagerState.openTab(new EntityDiffViewState(this.editorStore, SPECIAL_REVISION_ALIAS.LOCAL, SPECIAL_REVISION_ALIAS.WORKSPACE_HEAD, diff.oldPath, diff.newPath, fromEntity, toEntity, fromEntityGetter, toEntityGetter));
}
openPotentialWorkspacePullConflict(conflict) {
const baseEntityGetter = (entityPath) => entityPath
? this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState.entities.find((e) => e.path === entityPath)
: undefined;
const currentChangeEntityGetter = (entityPath) => entityPath
? this.editorStore.graphManagerState.graph.allOwnElements
.map((element) => this.editorStore.graphManagerState.graphManager.elementToEntity(element))
.find((e) => e.path === entityPath)
: undefined;
const incomingChangeEntityGetter = (entityPath) => entityPath
? this.editorStore.changeDetectionState.workspaceRemoteLatestRevisionState.entities.find((e) => e.path === entityPath)
: undefined;
const conflictEditorState = new EntityChangeConflictEditorState(this.editorStore, this.editorStore.conflictResolutionState, conflict.entityPath, SPECIAL_REVISION_ALIAS.WORKSPACE_BASE, SPECIAL_REVISION_ALIAS.LOCAL, SPECIAL_REVISION_ALIAS.WORKSPACE_HEAD, baseEntityGetter(conflict.entityPath), currentChangeEntityGetter(conflict.entityPath), incomingChangeEntityGetter(conflict.entityPath), baseEntityGetter, currentChangeEntityGetter, incomingChangeEntityGetter);
conflictEditorState.setReadOnly(true);
this.editorStore.tabManagerState.openTab(conflictEditorState);
}
*refreshLocalChanges() {
const startTime = Date.now();
this.refreshLocalChangesDetectorState.inProgress();
try {
// ======= (RE)START CHANGE DETECTION =======
this.editorStore.changeDetectionState.stop();
yield Promise.all([
this.sdlcState.buildWorkspaceLatestRevisionEntityHashesIndex(),
this.editorStore.changeDetectionState.preComputeGraphElementHashes(),
]);
this.editorStore.changeDetectionState.start();
this.editorStore.applicationStore.logService.info(LogEvent.create(LEGEND_STUDIO_APP_EVENT.CHANGE_DETECTION_RESTART__SUCCESS), Date.now() - startTime, 'ms');
// ======= FINISHED (RE)START CHANGE DETECTION =======
}
catch (error) {
assertErrorThrown(error);
this.editorStore.applicationStore.logService.error(LogEvent.create(LEGEND_STUDIO_APP_EVENT.SDLC_MANAGER_FAILURE), error);
this.editorStore.applicationStore.notificationService.notifyError(error);
this.sdlcState.handleChangeDetectionRefreshIssue(error);
}
finally {
this.refreshLocalChangesDetectorState.complete();
}
}
*processConflicts() {
try {
const isInConflictResolutionMode = (yield flowResult(this.sdlcState.checkIfCurrentWorkspaceIsInConflictResolutionMode()));
if (isInConflictResolutionMode) {
this.editorStore.applicationStore.alertService.setBlockingAlert({
message: 'Workspace is in conflict resolution mode',
prompt: 'Please refresh the application',
});
return;
}
}
catch (error) {
assertErrorThrown(error);
this.editorStore.applicationStore.notificationService.notifyWarning('Failed to check if current workspace is in conflict resolution mode');
return;
}
}
/**
* Get entitiy changes to prepare for syncing
*/
computeLocalEntityChanges() {
const baseHashesIndex = this.editorStore.isInConflictResolutionMode
? this.editorStore.changeDetectionState
.conflictResolutionHeadRevisionState.entityHashesIndex
: this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState
.entityHashesIndex;
const originalPaths = new Set(Array.from(baseHashesIndex.keys()));
const entityChanges = [];
this.editorStore.graphManagerState.graph.allOwnElements.forEach((element) => {
const elementPath = element.path;
if (baseHashesIndex.get(elementPath) !== element.hashCode) {
const entity = this.editorStore.graphManagerState.graphManager.elementToEntity(element, {
pruneSourceInformation: true,
});
entityChanges.push({
classifierPath: entity.classifierPath,
entityPath: element.path,
content: entity.content,
type: baseHashesIndex.get(elementPath) !== undefined
? EntityChangeType.MODIFY
: EntityChangeType.CREATE,
});
}
originalPaths.delete(elementPath);
});
Array.from(originalPaths).forEach((path) => {
entityChanges.push({
type: EntityChangeType.DELETE,
entityPath: path,
});
});
return entityChanges;
}
getCurrentHashIndexes() {
return this.editorStore.changeDetectionState.snapshotLocalEntityHashesIndex();
}
stopChangeDetection() {
this.editorStore.changeDetectionState.stop();
}
*restartChangeDetection() {
yield this.editorStore.changeDetectionState.preComputeGraphElementHashes();
this.editorStore.changeDetectionState.start();
yield Promise.all([
this.editorStore.changeDetectionState.computeAggregatedWorkspaceChanges(true),
]);
}
}
export class TextLocalChangesState extends LocalChangesState {
localChanges = [];
constructor(editorStore, sdlcState) {
super(editorStore, sdlcState);
makeObservable(this, {
setLocalChanges: action,
});
}
setLocalChanges(val) {
this.localChanges = val;
}
*refreshLocalChanges() {
this.refreshLocalChangesDetectorState.inProgress();
try {
yield Promise.all([
this.sdlcState.buildWorkspaceLatestRevisionEntityHashesIndex(),
this.editorStore.changeDetectionState.preComputeGraphElementHashes(),
]);
this.editorStore.changeDetectionState.computeLocalChangesInTextMode(this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState
.entities);
}
catch (error) {
assertErrorThrown(error);
this.editorStore.applicationStore.logService.error(LogEvent.create(LEGEND_STUDIO_APP_EVENT.SDLC_MANAGER_FAILURE), error);
this.editorStore.applicationStore.notificationService.notifyError(error);
this.sdlcState.handleChangeDetectionRefreshIssue(error);
}
finally {
this.refreshLocalChangesDetectorState.complete();
}
}
*processConflicts() {
return;
}
getCurrentHashIndexes() {
return this.editorStore.changeDetectionState
.workspaceLocalLatestRevisionState.currentEntityHashesIndex;
}
stopChangeDetection() {
this.localChanges = [];
}
computeLocalEntityChanges() {
return this.localChanges;
}
*restartChangeDetection() {
this.editorStore.changeDetectionState.computeLocalChangesInTextMode(this.editorStore.changeDetectionState.workspaceLocalLatestRevisionState
.entities);
}
}
//# sourceMappingURL=LocalChangesState.js.map