@finos/legend-application-studio
Version:
Legend Studio application core
956 lines (918 loc) • 37.4 kB
text/typescript
/**
* 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 {
type IReactionDisposer,
flowResult,
observable,
action,
reaction,
flow,
makeObservable,
isComputedProp,
} from 'mobx';
import { LEGEND_STUDIO_APP_EVENT } from '../../__lib__/LegendStudioEvent.js';
import {
type GeneratorFn,
LogEvent,
IllegalStateError,
shallowStringify,
noop,
assertErrorThrown,
hashObject,
promisify,
ActionState,
guaranteeNonNullable,
guaranteeType,
} from '@finos/legend-shared';
import type { EditorStore } from './EditorStore.js';
import type { EditorGraphState } from './EditorGraphState.js';
import type { Entity } from '@finos/legend-storage';
import {
type EntityChangeConflictResolution,
EntityChangeConflict,
EntityChangeType,
EntityDiff,
type EntityChange,
} from '@finos/legend-server-sdlc';
import {
ObserverContext,
observe_Graph,
observe_GraphElements,
} from '@finos/legend-graph';
import { type IDisposer, keepAlive } from 'mobx-utils';
import { TextLocalChangesState } from './sidebar-state/LocalChangesState.js';
class RevisionChangeDetectionState {
editorStore: EditorStore;
graphState: EditorGraphState;
changes: EntityDiff[] = [];
entityHashesIndex = new Map<string, string>();
isBuildingEntityHashesIndex = false;
entities: Entity[] = [];
currentEntityHashesIndex = new Map<string, string>();
setEntityHashesIndex(hashesIndex: Map<string, string>): void {
this.entityHashesIndex = hashesIndex;
}
setIsBuildingEntityHashesIndex(building: boolean): void {
this.isBuildingEntityHashesIndex = building;
}
setEntities(entities: Entity[]): void {
this.entities = entities;
}
constructor(editorStore: EditorStore, graphState: EditorGraphState) {
makeObservable(this, {
changes: observable.ref,
entityHashesIndex: observable.ref,
entities: observable.ref,
isBuildingEntityHashesIndex: observable,
setEntityHashesIndex: action,
setIsBuildingEntityHashesIndex: action,
setEntities: action,
computeChanges: flow,
computeChangesInTextMode: flow,
buildEntityHashesIndex: flow,
});
this.editorStore = editorStore;
this.graphState = graphState;
}
*computeChanges(quiet?: boolean): GeneratorFn<void> {
const startTime = Date.now();
let changes: EntityDiff[] = [];
if (!this.isBuildingEntityHashesIndex) {
const originalPaths = new Set(Array.from(this.entityHashesIndex.keys()));
if (this.editorStore.graphManagerState.graph.allOwnElements.length) {
yield Promise.all<void>(
this.editorStore.graphManagerState.graph.allOwnElements.map(
(element) =>
promisify(() => {
const elementPath = element.path;
const originalElementHash =
this.entityHashesIndex.get(elementPath);
if (!originalElementHash) {
changes.push(
new EntityDiff(
undefined,
elementPath,
EntityChangeType.CREATE,
),
);
} else if (originalElementHash !== element.hashCode) {
changes.push(
new EntityDiff(
elementPath,
elementPath,
EntityChangeType.MODIFY,
),
);
}
originalPaths.delete(elementPath);
}),
),
);
}
changes = changes.concat(
Array.from(originalPaths).map(
(path) => new EntityDiff(path, undefined, EntityChangeType.DELETE),
),
);
}
this.changes = changes;
if (!quiet) {
this.editorStore.applicationStore.logService.info(
LogEvent.create(
LEGEND_STUDIO_APP_EVENT.CHANGE_DETECTION_COMPUTE_CHANGES__SUCCESS,
),
Date.now() - startTime,
'ms',
);
}
}
*computeChangesInTextMode(
currentEntities: Entity[],
quiet?: boolean,
): GeneratorFn<void> {
const startTime = Date.now();
const changes: EntityDiff[] = [];
const entityChanges: EntityChange[] = [];
if (!this.isBuildingEntityHashesIndex) {
let currentHashesIndex;
if (currentEntities.length) {
currentHashesIndex =
(yield this.editorStore.graphManagerState.graphManager.buildHashesIndex(
currentEntities,
)) as Map<string, string>;
this.currentEntityHashesIndex = currentHashesIndex;
}
const originalPaths = new Set(Array.from(this.entityHashesIndex.keys()));
if (currentHashesIndex) {
yield Promise.all<void>(
Array.from(currentHashesIndex.entries()).map(
([elementPath, elementHash]) =>
promisify(() => {
const entity = currentEntities.find(
(e) => e.path === elementPath,
);
const originalElementHash =
this.entityHashesIndex.get(elementPath);
if (!originalElementHash) {
changes.push(
new EntityDiff(
undefined,
elementPath,
EntityChangeType.CREATE,
),
);
entityChanges.push({
classifierPath: guaranteeNonNullable(entity).classifierPath,
entityPath: guaranteeNonNullable(entity).path,
content: guaranteeNonNullable(entity).content,
type: EntityChangeType.CREATE,
});
} else if (originalElementHash !== elementHash) {
changes.push(
new EntityDiff(
elementPath,
elementPath,
EntityChangeType.MODIFY,
),
);
entityChanges.push({
classifierPath: guaranteeNonNullable(entity).classifierPath,
entityPath: guaranteeNonNullable(entity).path,
content: guaranteeNonNullable(entity).content,
type: EntityChangeType.MODIFY,
});
}
originalPaths.delete(elementPath);
}),
),
);
}
yield promisify(() => {
Array.from(originalPaths).forEach((path) => {
changes.push(
new EntityDiff(path, undefined, EntityChangeType.DELETE),
);
entityChanges.push({
type: EntityChangeType.DELETE,
entityPath: path,
});
});
});
}
this.changes = changes;
guaranteeType(
this.editorStore.localChangesState,
TextLocalChangesState,
).localChanges = entityChanges;
if (!quiet) {
this.editorStore.applicationStore.logService.info(
LogEvent.create(
LEGEND_STUDIO_APP_EVENT.CHANGE_DETECTION_COMPUTE_CHANGES__SUCCESS,
),
Date.now() - startTime,
'ms',
);
}
}
*buildEntityHashesIndex(
entities: Entity[],
logEvent: LogEvent,
quiet?: boolean,
): GeneratorFn<void> {
if (!this.entities.length && !this.entityHashesIndex.size) {
return;
}
const startTime = Date.now();
this.setIsBuildingEntityHashesIndex(true);
try {
const hashesIndex =
(yield this.editorStore.graphManagerState.graphManager.buildHashesIndex(
entities,
)) as Map<string, string>;
this.setEntityHashesIndex(hashesIndex);
this.setIsBuildingEntityHashesIndex(false);
if (!quiet) {
this.editorStore.applicationStore.logService.info(
logEvent,
'[ASYNC]',
Date.now() - startTime,
'ms',
);
}
} catch (error) {
assertErrorThrown(error);
this.editorStore.applicationStore.logService.error(
LogEvent.create(LEGEND_STUDIO_APP_EVENT.CHANGE_DETECTION__FAILURE),
`Can't build hashes index`,
);
this.setEntityHashesIndex(new Map<string, string>());
this.setIsBuildingEntityHashesIndex(false);
throw new IllegalStateError(error);
} finally {
this.setIsBuildingEntityHashesIndex(false);
}
}
}
/**
* In the SDLC flow of the app, there are several important revision that we want to keep track of. See diagram below:
*
* (1. PJL)
* |
* |
* (2. CRB) ------- (3. CRH) ------ (4. CRL)
* |
* |
* (5. WSB) ------- (6. WSH) ------ (7. WSL)
* |
* ... (earlier revisions in the project)
*
* Annotations:
* 1. PJL: Project HEAD revision
* 2. CRB: Conflict resolution BASE revision
* 3. CRH: Conflict resolution HEAD revision
* 4. CRL: Conflict resolution LIVE revision (i.e. local graph state in conflict resolution mode)
* 5. WSB: Workspace BASE revision
* 6. WSH: Workspace HEAD revision
* 7. WSL: Workspace LIVE revision (i.e. local graph state in standard mode)
*/
export class ChangeDetectionState {
editorStore: EditorStore;
graphState: EditorGraphState;
graphObserveState = ActionState.create();
initState = ActionState.create();
/**
* Keep the list of disposers to deactivate `keepAlive` for computed value of element hash code.
* See {@link preComputeGraphElementHashes} for more details
*/
private graphElementHashCodeKeepAliveComputationDisposers: IDisposer[] = [];
private changeDetectionReaction?: IReactionDisposer | undefined;
/**
* [1. PJL] Store the entities from project HEAD (i.e. project latest revision)
* This can be used to compute changes for a review as well as changes and potential conflicts when updating workspace
*/
projectLatestRevisionState: RevisionChangeDetectionState;
/**
* [2. CRB] Store the entities from the BASE revision of workspace with conflict resolution (this is different from the current workspace the user is on)
* NOTE: the flow for conflict resolution is briefly like this (assume current user workspace is `w1`):
* 1. When the user chooses to update workspace `w1`, the backend will compute changes between `w1` HEAD and `w1` BASE
* 2. Create a new conflict resolution branch on top of project HEAD
* 3. Apply the changes on this branch.
*
* So we now have 2 branchs normal `w1` and `w1` in conflict resolution. From the user perspective, they might not need to know this
* So in the app, we have to check for the existence of the conflict resolution branch and make it supercede the original `w1` branch
*
* This branch, thus, will be used to show the users all the changes they have on top of conflict resolution BASE revision
* during conflict resolution stage
*/
conflictResolutionBaseRevisionState: RevisionChangeDetectionState;
/**
* [3. CRH] Store the entities from the conflict resolution HEAD revision
* This is used for computing the live diff, so that when we mark conflicts as resolved and accept conflict resolution
* we can compute the entity changes
*/
conflictResolutionHeadRevisionState: RevisionChangeDetectionState;
/**
* [5. WSB] Store the entities from workspace BASE revision
* This can be used for conflict resolution
*/
workspaceBaseRevisionState: RevisionChangeDetectionState;
/**
* [6. WSH] Store the entities from LOCAL workspace HEAD revision.
* This can be used for computing local changes/live diffs (i.e. changes between local graph and workspace HEAD)
*/
workspaceLocalLatestRevisionState: RevisionChangeDetectionState;
/**
* Store the entities from remote workspace HEAD revision.
* This can be used for computing the diffs between local workspace and remote workspace to check if the local workspace is out-of-sync
*/
workspaceRemoteLatestRevisionState: RevisionChangeDetectionState;
aggregatedWorkspaceChanges: EntityDiff[] = []; // review/merge-request changes
aggregatedProjectLatestChanges: EntityDiff[] = []; // project latest changes - used for updating workspace
aggregatedWorkspaceRemoteChanges: EntityDiff[] = []; // review/merge-request changes
potentialWorkspaceUpdateConflicts: EntityChangeConflict[] = []; // potential conflicts when updating workspace (derived from aggregated workspace changes and project latest changes)
potentialWorkspacePullConflicts: EntityChangeConflict[] = [];
/**
* For conflict resolution, the procedure is split into 2 steps:
* 1. The user resolves conflicts (no graph is built at this point)
* 2. The user marks all conflicts as resolved and starts building the graph to fix any residual problems
*
* Ideally, we would like to use the live diff (conflict resolution BASE <-> local graph), but since for step 1
* we do not build the graph, this is not possible, so we have to use the following to store the diff until we move to step 2
*/
aggregatedConflictResolutionChanges: EntityDiff[] = [];
conflicts: EntityChangeConflict[] = []; // conflicts in conflict resolution mode (derived from aggregated workspace changes and conflict resolution changes)
resolutions: EntityChangeConflictResolution[] = [];
currentGraphHash: string | undefined;
observerContext: ObserverContext;
constructor(editorStore: EditorStore, graphState: EditorGraphState) {
makeObservable(this, {
resolutions: observable,
currentGraphHash: observable,
projectLatestRevisionState: observable.ref,
conflictResolutionBaseRevisionState: observable.ref,
conflictResolutionHeadRevisionState: observable.ref,
workspaceBaseRevisionState: observable.ref,
workspaceLocalLatestRevisionState: observable.ref,
workspaceRemoteLatestRevisionState: observable,
aggregatedWorkspaceChanges: observable.ref,
aggregatedProjectLatestChanges: observable.ref,
aggregatedWorkspaceRemoteChanges: observable.ref,
potentialWorkspacePullConflicts: observable.ref,
potentialWorkspaceUpdateConflicts: observable.ref,
aggregatedConflictResolutionChanges: observable.ref,
conflicts: observable.ref,
setAggregatedProjectLatestChanges: action,
setPotentialWorkspaceUpdateConflicts: action,
stop: action,
start: action,
computeAggregatedWorkspaceChanges: flow,
computeAggregatedProjectLatestChanges: flow,
computeAggregatedConflictResolutionChanges: flow,
computeWorkspaceUpdateConflicts: flow,
computeConflictResolutionConflicts: flow,
computeEntityChangeConflicts: flow,
computeLocalChanges: flow,
computeLocalChangesInTextMode: flow,
computeAggregatedWorkspaceRemoteChanges: flow,
observeGraph: flow,
});
this.editorStore = editorStore;
this.graphState = graphState;
this.workspaceLocalLatestRevisionState = new RevisionChangeDetectionState(
editorStore,
graphState,
);
this.workspaceRemoteLatestRevisionState = new RevisionChangeDetectionState(
editorStore,
graphState,
);
this.workspaceBaseRevisionState = new RevisionChangeDetectionState(
editorStore,
graphState,
);
this.projectLatestRevisionState = new RevisionChangeDetectionState(
editorStore,
graphState,
);
// conflict resolution
this.conflictResolutionHeadRevisionState = new RevisionChangeDetectionState(
editorStore,
graphState,
);
this.conflictResolutionBaseRevisionState = new RevisionChangeDetectionState(
editorStore,
graphState,
);
this.observerContext = new ObserverContext(
this.editorStore.pluginManager.getPureGraphManagerPlugins(),
);
}
setAggregatedProjectLatestChanges(diffs: EntityDiff[]): void {
this.aggregatedProjectLatestChanges = diffs;
}
setAggregatedWorkspaceRemoteChanges(diffs: EntityDiff[]): void {
this.aggregatedWorkspaceRemoteChanges = diffs;
}
setPotentialWorkspaceUpdateConflicts(
conflicts: EntityChangeConflict[],
): void {
this.potentialWorkspaceUpdateConflicts = conflicts;
}
setPotentialWorkspacePullConflicts(conflicts: EntityChangeConflict[]): void {
this.potentialWorkspacePullConflicts = conflicts;
}
getCurrentGraphHash(): string {
return hashObject(
Array.from(this.snapshotLocalEntityHashesIndex(true).entries())
// sort to ensure this array order does not affect change detection status
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([key, value]) => `${key}@${value}`),
);
}
stop(force = false): void {
this.changeDetectionReaction?.();
this.changeDetectionReaction = undefined;
if (force) {
this.initState.fail();
} else {
this.initState.reset();
}
}
/**
* The change detection check is not free, although due to the power of mobx's computed, this is really fast
* but we want to use a reaction here instead of having changes as a computed getter is that:
* 1. We want to debounce the action
* 2. We want to control when we would start observing for changes (this is useful since we need to compute the initial
* hashes index before this, otherwise users will get false report about the number of changes)
* This function might cause the UI to jank the since it involves expensive computation of the all the elements' hashes
* for the first time. Currently there is no workaround so we might need to comeback and evaluate this
*/
start(): void {
this.changeDetectionReaction?.();
/**
* It seems like the reaction action is not always called in tests, causing fluctuation in
* code coverage report for this file. As such, for test, we would want to disable throttling
* to avoid timing issue.
*
* See https://docs.codecov.io/docs/unexpected-coverage-changes
* See https://community.codecov.io/t/codecov-reporting-impacted-files-for-unchanged-and-completely-unrelated-file/2635
*/
// eslint-disable-next-line no-process-env
const throttleDuration = process.env.NODE_ENV === 'test' ? 0 : 1000;
/**
* For the reaction, the data we observe is the snapshot of the current graph's hash index.
* This will loop through all elements' and compute the hashCode for the first time
* so in subsequent calls this could be fast. This is expensive and the optimization we choose is
* to parallelize using promises, but since `mobx` only tracks dependency synchronously,
* we would lose the benefit of `mobx` computation
* See https://github.com/danielearwicker/computed-async-mobx#gotchas
* See https://github.com/mobxjs/mobx/issues/668
* See https://github.com/mobxjs/mobx/issues/872
*
* So now, we have 2 options:
* 1. We can let the UI freezes up for a short while by calling `this.snapshotCurrentHashesIndex(true)`
* 2. We will use `keepAlive` for elements that we care about for building the hashes index, i.e. class, mapping, diagram, etc.
*
* We will go with (2) but we have to note that `keepAlive` can cause memory leak. As such, activating `keepAlive` is
* a fairly non-trivial step, see {@link preComputeGraphElementHashes} for more details
*
* See https://mobx.js.org/computeds.html#keepalive
* See https://medium.com/terria/when-and-why-does-mobxs-keepalive-cause-a-memory-leak-8c29feb9ff55
*/
this.changeDetectionReaction = reaction(
() => this.getCurrentGraphHash(),
() => {
flowResult(this.computeLocalChanges(true)).catch(noop());
this.currentGraphHash = this.getCurrentGraphHash();
},
{
// fire reaction immediately to compute the first changeset
fireImmediately: true,
// throttle the call
delay: throttleDuration,
},
);
// set initial current graph hash
this.currentGraphHash = this.getCurrentGraphHash();
// dispose and remove the disposers for `keepAlive` computations for elements' hash code
this.graphElementHashCodeKeepAliveComputationDisposers.forEach((disposer) =>
disposer(),
);
this.graphElementHashCodeKeepAliveComputationDisposers = [];
this.initState.pass();
}
snapshotLocalEntityHashesIndex(quiet?: boolean): Map<string, string> {
const startTime = Date.now();
const snapshot = new Map<string, string>();
this.editorStore.graphManagerState.graph.allOwnElements.forEach((el) =>
snapshot.set(el.path, el.hashCode),
);
if (!quiet) {
this.editorStore.applicationStore.logService.info(
LogEvent.create(
LEGEND_STUDIO_APP_EVENT.CHANGE_DETECTION_BUILD_GRAPH_HASHES_INDEX__SUCCESS,
),
Date.now() - startTime,
'ms',
);
}
return snapshot;
}
private computeAggregatedChangesBetweenStates = async (
fromState: RevisionChangeDetectionState,
toState: RevisionChangeDetectionState,
quiet?: boolean,
): Promise<EntityDiff[]> => {
const startTime = Date.now();
let changes: EntityDiff[] = [];
if (
!fromState.isBuildingEntityHashesIndex &&
!toState.isBuildingEntityHashesIndex
) {
const originalPaths = new Set(
Array.from(fromState.entityHashesIndex.keys()),
);
await Promise.all<void>(
Array.from(toState.entityHashesIndex.entries()).map(
([elementPath, hashCode]) =>
promisify(() => {
const originalElementHashCode =
fromState.entityHashesIndex.get(elementPath);
if (!originalElementHashCode) {
changes.push(
new EntityDiff(
undefined,
elementPath,
EntityChangeType.CREATE,
),
);
} else if (originalElementHashCode !== hashCode) {
changes.push(
new EntityDiff(
elementPath,
elementPath,
EntityChangeType.MODIFY,
),
);
}
originalPaths.delete(elementPath);
}),
),
);
changes = changes.concat(
Array.from(originalPaths).map(
(path) => new EntityDiff(path, undefined, EntityChangeType.DELETE),
),
);
if (!quiet) {
this.editorStore.applicationStore.logService.info(
LogEvent.create(
LEGEND_STUDIO_APP_EVENT.CHANGE_DETECTION_COMPUTE_CHANGES__SUCCESS,
),
Date.now() - startTime,
'ms',
);
}
}
return changes;
};
*computeAggregatedWorkspaceChanges(quiet?: boolean): GeneratorFn<void> {
this.aggregatedWorkspaceChanges =
(yield this.computeAggregatedChangesBetweenStates(
this.workspaceBaseRevisionState,
this.workspaceLocalLatestRevisionState,
quiet,
)) as EntityDiff[];
yield Promise.all([
this.computeWorkspaceUpdateConflicts(quiet),
this.computeConflictResolutionConflicts(quiet),
]);
}
*computeAggregatedWorkspaceRemoteChanges(quiet?: boolean): GeneratorFn<void> {
this.aggregatedWorkspaceRemoteChanges =
(yield this.computeAggregatedChangesBetweenStates(
this.workspaceLocalLatestRevisionState,
this.workspaceRemoteLatestRevisionState,
quiet,
)) as EntityDiff[];
const conflicts = (yield flowResult(
this.computeEntityChangeConflicts(
this.workspaceLocalLatestRevisionState.changes,
this.aggregatedWorkspaceRemoteChanges,
this.snapshotLocalEntityHashesIndex(),
this.workspaceRemoteLatestRevisionState.entityHashesIndex,
),
)) as EntityChangeConflict[];
this.setPotentialWorkspacePullConflicts(conflicts);
}
*computeAggregatedProjectLatestChanges(quiet?: boolean): GeneratorFn<void> {
this.aggregatedProjectLatestChanges =
(yield this.computeAggregatedChangesBetweenStates(
this.workspaceBaseRevisionState,
this.projectLatestRevisionState,
quiet,
)) as EntityDiff[];
yield flowResult(this.computeWorkspaceUpdateConflicts(quiet));
}
*computeAggregatedConflictResolutionChanges(
quiet?: boolean,
): GeneratorFn<void> {
this.aggregatedConflictResolutionChanges =
(yield this.computeAggregatedChangesBetweenStates(
this.conflictResolutionBaseRevisionState,
this.conflictResolutionHeadRevisionState,
quiet,
)) as EntityDiff[];
}
/**
* Workspace update conflicts are computed between 2 sets of changes:
* 1. Incoming changes: changes between workspace BASE revision and project LATEST revision
* 2. Current changes: changes between workspace BASE revision and workspace HEAD revision
*/
*computeWorkspaceUpdateConflicts(quiet?: boolean): GeneratorFn<void> {
const startTime = Date.now();
this.potentialWorkspaceUpdateConflicts = (yield flowResult(
this.computeEntityChangeConflicts(
this.aggregatedWorkspaceChanges,
this.aggregatedProjectLatestChanges,
this.workspaceLocalLatestRevisionState.entityHashesIndex,
this.projectLatestRevisionState.entityHashesIndex,
),
)) as EntityChangeConflict[];
if (!quiet) {
this.editorStore.applicationStore.logService.info(
LogEvent.create(
LEGEND_STUDIO_APP_EVENT.CHANGE_DETECTION_COMPUTE_WORKSPACE_UPDATE_CONFLICTS__SUCCESS,
),
Date.now() - startTime,
'ms',
);
}
}
/**
* Conflict resolution conflicts are computed between 2 sets of changes:
* 1. Incoming changes: changes between workspace BASE revision and conflict resolution BASE revision
* 2. Current changes: changes between workspace BASE revision and workspace HEAD revision
*/
*computeConflictResolutionConflicts(quiet?: boolean): GeneratorFn<void> {
const aggregatedUpdateChanges =
(yield this.computeAggregatedChangesBetweenStates(
this.workspaceBaseRevisionState,
this.conflictResolutionBaseRevisionState,
quiet,
)) as EntityDiff[];
const startTime = Date.now();
this.conflicts = (yield flowResult(
this.computeEntityChangeConflicts(
this.aggregatedWorkspaceChanges,
aggregatedUpdateChanges,
this.workspaceLocalLatestRevisionState.entityHashesIndex,
this.conflictResolutionBaseRevisionState.entityHashesIndex,
),
)) as EntityChangeConflict[];
if (!quiet) {
this.editorStore.applicationStore.logService.info(
LogEvent.create(
LEGEND_STUDIO_APP_EVENT.CHANGE_DETECTION_COMPUTE_CONFLICT_RESOLUTION_CONFLICTS__SUCCESS,
),
Date.now() - startTime,
'ms',
);
}
}
/**
* This function computes the entity change conflicts between 2 set of entity changes (let's call them incoming changes and current changes).
* For a more comprehensive explanation, we take a look at how we can use this to compute potential conflicts during workspace update:
*
* To compute potential conflicts during workspace update, we must base off the project latest changes [incChg] (workspace BASE <-> project HEAD)
* and the merge request changes [currChng] (workspace BASE <-> workspace HEAD). We have a case table below (`N.A.` means it's impossible cases)
* For cases we with `conflict` there might be potential conflicts as the change to the entity appear in both [incChg] and [currChng]. But we must
* note that this is `potential` because we cannot be too sure how SDCL server handle merging these during update.
*
* NOTE: it's important to remember that these are truly potential conflicts, because of git merge mechanism,
* it will apply one intermediate commit at a time, this means that if, we have a file A:
*
* Workspace change: 1. A is deleted; 2. A is created with content `a`
* Project latest change: 1. A is modified with content `a`
*
* These could mean no conflict from our computation but is a conflict when Git tries to merge.
*
* NOTE: Also, there could be strange case for SVN that a file can be DELETED and CREATED, it's called "replace".
*
* | [incChg] | | | |
* -----------------------------------------------------------
* [currChng] | | CREATE | DELETE | MODIFY |
* -----------------------------------------------------------
* | CREATE | conflict | N.A. | N.A. |
* -----------------------------------------------------------
* | DELETE | N.A. | none | conflict |
* -----------------------------------------------------------
* | MODIFY | N.A. | conflict | conflict |
* -----------------------------------------------------------
*/
*computeEntityChangeConflicts(
currentChanges: EntityDiff[],
incomingChanges: EntityDiff[],
currentChangeEntityHashesIndex: Map<string, string>,
incomingChangeEntityHashesIndex: Map<string, string>,
): GeneratorFn<EntityChangeConflict[]> {
const conflicts: EntityChangeConflict[] = [];
const currentChangesMap = currentChanges.reduce(
(diffMap, currentDiff) =>
diffMap.set(currentDiff.entityPath, currentDiff),
new Map<string, EntityDiff>(),
);
const incomingChangesMap = incomingChanges.reduce(
(diffMap, currentDiff) =>
diffMap.set(currentDiff.entityPath, currentDiff),
new Map<string, EntityDiff>(),
);
yield Promise.all<void>(
Array.from(incomingChangesMap.entries()).map(
([entityPath, incomingChange]: [string, EntityDiff]) =>
promisify(() => {
const currentChange = currentChangesMap.get(entityPath); // find the change on the same entity in the set of current changes
if (currentChange) {
if (
(currentChange.entityChangeType === EntityChangeType.CREATE &&
incomingChange.entityChangeType ===
EntityChangeType.CREATE) ||
(currentChange.entityChangeType === EntityChangeType.MODIFY &&
incomingChange.entityChangeType === EntityChangeType.MODIFY)
) {
// if the two entities are identical, we can guarantee no conflict happens, otherwise, depending on the SDLC server, we might get a conflict.
// NOTE: we actually want the potential conflict to be a real conflict in this case because SDLC server while attempting to merge the protocol JSON
// might actually mess up the entity, which is very bad
if (
currentChangeEntityHashesIndex.get(entityPath) !==
incomingChangeEntityHashesIndex.get(entityPath)
) {
conflicts.push(
new EntityChangeConflict(
entityPath,
incomingChange,
currentChange,
),
);
}
} else if (
(currentChange.entityChangeType === EntityChangeType.DELETE &&
incomingChange.entityChangeType ===
EntityChangeType.MODIFY) ||
(currentChange.entityChangeType === EntityChangeType.MODIFY &&
incomingChange.entityChangeType === EntityChangeType.DELETE)
) {
conflicts.push(
new EntityChangeConflict(
entityPath,
incomingChange,
currentChange,
),
);
} else if (
currentChange.entityChangeType === EntityChangeType.DELETE &&
incomingChange.entityChangeType === EntityChangeType.DELETE
) {
// do nothing
} else {
throw new IllegalStateError(
`Detected unfeasible state while computing entity change conflict for entity '${entityPath}', with current change: ${shallowStringify(
currentChange,
)}, and incoming change: ${shallowStringify(incomingChange)}`,
);
}
}
}),
),
);
return conflicts;
}
/**
* NOTE: here we have not dealt with non-entity changes like project dependency for example.
* We will have to count that as part of the change in the future.
*/
*computeLocalChanges(quiet?: boolean): GeneratorFn<void> {
const startTime = Date.now();
yield Promise.all([
this.workspaceLocalLatestRevisionState.computeChanges(quiet), // for local changes detection
this.conflictResolutionBaseRevisionState.computeChanges(quiet), // for conflict resolution changes detection
]);
if (!quiet) {
this.editorStore.applicationStore.logService.info(
LogEvent.create(
LEGEND_STUDIO_APP_EVENT.CHANGE_DETECTION_COMPUTE_CHANGES__SUCCESS,
),
Date.now() - startTime,
'ms',
);
}
}
*computeLocalChangesInTextMode(
currentEntities: Entity[],
quiet?: boolean,
): GeneratorFn<void> {
const startTime = Date.now();
yield Promise.all([
this.workspaceLocalLatestRevisionState.computeChangesInTextMode(
currentEntities,
quiet,
),
]);
if (!quiet) {
this.editorStore.applicationStore.logService.info(
LogEvent.create(
LEGEND_STUDIO_APP_EVENT.CHANGE_DETECTION_COMPUTE_CHANGES__SUCCESS,
),
Date.now() - startTime,
'ms',
);
}
this.initState.pass();
}
*observeGraph(): GeneratorFn<void> {
if (this.initState.hasSucceeded) {
throw new IllegalStateError(
`Can't observe graph: change detection must be stopped first`,
);
}
if (this.editorStore.graphManagerState.graph.origin) {
throw new IllegalStateError(
`Can't observe graph: can't change graph with sdlc pointer`,
);
}
this.graphObserveState.inProgress();
this.graphObserveState.setMessage(`Observing graph...`);
const startTime = Date.now();
// NOTE: this method has to be done synchronously in `action` context
// to make sure `mobx` react to observables from the graph, such as its element indices
observe_Graph(this.editorStore.graphManagerState.graph);
// this will be done asynchronously to improve performance
yield observe_GraphElements(
this.editorStore.graphManagerState.graph,
this.editorStore.changeDetectionState.observerContext,
);
this.editorStore.applicationStore.logService.info(
LogEvent.create(
LEGEND_STUDIO_APP_EVENT.CHANGE_DETECTION_OBSERVE_GRAPH__SUCCESS,
),
'[ASYNC]',
Date.now() - startTime,
'ms',
);
this.graphObserveState.setMessage(undefined);
this.graphObserveState.pass();
}
/**
* Call `get hashCode()` on each element once so we trigger the first time we compute the hash for that element.
* Notice that we do this in asynchronous manner to not block the main execution thread, as this is quiet an expensive
* task.
*
* We also want to take advantage of `mobx computed` here so we save time when starting change detection. However,
* since `mobx computed` does not track async contexts, we have to use the `keepAlive` option for `computed`
*
* To avoid memory leak potentially caused by `keepAlive`, we use `keepAlive` utility from `mobx-utils`
* so we could manually dispose `keepAlive` later after we already done with starting change detection.
*/
async preComputeGraphElementHashes(): Promise<void> {
const startTime = Date.now();
const disposers: IDisposer[] = [];
if (this.editorStore.graphManagerState.graph.allOwnElements.length) {
await Promise.all<void>(
this.editorStore.graphManagerState.graph.allOwnElements.map((element) =>
promisify(() => {
if (isComputedProp(element, 'hashCode')) {
disposers.push(keepAlive(element, 'hashCode'));
}
// manually trigger hash code computation
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
element.hashCode;
}),
),
);
}
// save the `keepAlive` computation disposers to dispose after we start change detection
// so the first call of change detection still get the benefit of computed value
this.graphElementHashCodeKeepAliveComputationDisposers = disposers;
this.editorStore.applicationStore.logService.info(
LogEvent.create(
LEGEND_STUDIO_APP_EVENT.CHANGE_DETECTION_PRECOMPUTE_GRAPH_HASHES__SUCCESS,
),
'[ASYNC]',
Date.now() - startTime,
'ms',
);
}
}