Pārlūkot izejas kodu

Implemented 'undo' for primitive+composite deltas. Conflict relations between deltas are drawn in dependency graph.

Joeri Exelmans 2 gadi atpakaļ
vecāks
revīzija
ff23a83a4e

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 283 - 137
pnpm-lock.yaml


+ 227 - 43
src/frontend/app.tsx

@@ -1,7 +1,9 @@
 import * as React from "react";
 import * as _ from "lodash";
 
-import {Grid, Text, Title, Group, Stack} from "@mantine/core";
+import {Grid, Text, Title, Group, Stack, SimpleGrid, Button, Space} from "@mantine/core";
+
+import {IconPlayerTrackPrev, IconPlayerTrackNext} from "@tabler/icons";
 
 import {d3Types, Graph} from "./graph"
 import {EditableGraph, GraphType, NodeType, LinkType} from "./editable_graph";
@@ -35,6 +37,7 @@ class D3StateManipulator implements GraphStateManipulator {
         y: this.y,
         color: "darkturquoise",
         obj: ns,
+        highlight: false,
       }],
       links: prevGraph.links,
     }));
@@ -53,7 +56,8 @@ class D3StateManipulator implements GraphStateManipulator {
       links: [...prevGraph.links, {
         source: prevGraph.nodes.find(n => n.obj.creation.id.value === sourceId),
         target: prevGraph.nodes.find(n => n.obj.creation.id.value === targetId),
-        label,
+        label: label,
+        color: 'black',
         obj: null,
       }],
     }));
@@ -78,11 +82,29 @@ const emptyGraph = {
   links: [],
 };
 
+type HistoryGraphType = d3Types.d3Graph<Version,Delta|null>;
+type DependencyGraphType = d3Types.d3Graph<Delta,null>;
+
 const initialHistoryGraph = {
-  nodes: [versionToNode(initialVersion)],
-  links: [],
+  nodes: [
+    {id: "cur", label: "", color: "grey", obj: null},
+    versionToNode(initialVersion),
+  ],
+  links: [
+    currentVersionLink(initialVersion),
+  ],
 };
 
+
+function updateCurrentVersionLink(prevHistoryGraph: HistoryGraphType, newCurrentVersion) {
+  return {
+    nodes: prevHistoryGraph.nodes,
+    links: prevHistoryGraph.links.filter(link => link.source.id !== "cur").concat(
+      currentVersionLink(newCurrentVersion)
+    ),
+  }
+}
+
 function fullVersionId(version: Version): string {
   return version.hash.toString('base64');
 }
@@ -97,8 +119,12 @@ function versionToNode(version: Version): d3Types.d3Node<Version> {
     label: version.hash.toString('hex').slice(0,8),
     color: "purple",
     obj: version,
+    highlight: false,
   }
 }
+function currentVersionLink(version: Version): d3Types.d3Link<null> {
+  return { source: "cur", label: "", target: fullVersionId(version), color: 'grey', obj: null };
+}
 
 function shortDeltaId(delta: Delta) {
   return delta.getHash().toString('hex').slice(0,8);
@@ -133,7 +159,7 @@ function getDeltaColor(delta: Delta) {
 }
 
 interface BranchProps {
-  getUuid: () => UUID;
+  generateUUID: () => UUID;
 }
 
 export function Branch(props: BranchProps) {
@@ -141,62 +167,212 @@ export function Branch(props: BranchProps) {
   const [graph, setGraph] = React.useState<GraphType>(_.cloneDeep(emptyGraph));
   const [manipulator, setManipulator] = React.useState<D3StateManipulator>(new D3StateManipulator(setGraph));
   const [graphDeltaExecutor, setGraphDeltaExecutor] = React.useState<GraphDeltaExecutor>(new GraphDeltaExecutor(manipulator));
-  const [historyGraph, setHistoryGraph] = React.useState<d3Types.d3Graph<Version,Delta>>(_.cloneDeep(initialHistoryGraph));
-  const [dependencyGraph, setDependencyGraph] = React.useState<d3Types.d3Graph<Delta,null>>(_.cloneDeep(emptyGraph));
+  const [historyGraph, setHistoryGraph] = React.useState<HistoryGraphType>(_.cloneDeep(initialHistoryGraph));
+  const [dependencyGraph, setDependencyGraph] = React.useState<DependencyGraphType>(_.cloneDeep(emptyGraph));
 
   const newVersionHandler = (version: Version) => {
     setHistoryGraph(prevHistoryGraph => {
-      return {
-        nodes: prevHistoryGraph.nodes.concat(versionToNode(version)),
-        links: prevHistoryGraph.links.concat(...version.parents.map(([parentVersion,delta]) => ({source:fullVersionId(version), label:"", target:fullVersionId(parentVersion), obj: delta}))),
-      };
+      const newLinks = version.parents.map(([parentVersion,delta]) => ({source:fullVersionId(version), label:"", target:fullVersionId(parentVersion), color: 'black', obj: delta})).filter(link=>!prevHistoryGraph.links.some(prevLink => prevLink.source.id===link.source&&prevLink.target.id===link.target));
+
+      return updateCurrentVersionLink({
+        // only add node if the version does not yet exist in the history graph:
+        nodes: prevHistoryGraph.nodes.some(node => node.id === fullVersionId(version)) ?
+          prevHistoryGraph.nodes : prevHistoryGraph.nodes.concat(versionToNode(version)),
+
+        // create parent link if that link does not yet exist
+        links: prevHistoryGraph.links.concat(...newLinks),
+      }, version);
     });
     setVersion(version);
   };
   const newDeltaHandler = (delta: Delta) => {
+    if (dependencyGraph.nodes.some(node => node.id === fullDeltaId(delta))) {
+      // We already have this delta (remember that delta's are identified by the hash of their contents, so it is possible that different people concurrently create the same deltas, e.g., by deleting the same node concurrently)
+      return; // do nothing
+    }
     setDependencyGraph(prevDepGraph => {
       const color = getDeltaColor(delta);
       return {
-        nodes: prevDepGraph.nodes.concat({id: fullDeltaId(delta), label: delta.getDescription(), color, obj: delta}),
-        links: prevDepGraph.links.concat(...delta.getTypedDependencies().map(([dep,depType]) => ({source: fullDeltaId(delta), label: depType, target: fullDeltaId(dep), obj: null}))),
+        // add one extra node that represents the new delta:
+        nodes: prevDepGraph.nodes.concat({id: fullDeltaId(delta), label: delta.getDescription(), color, highlight: false, obj: delta}),
+        // for every dependency and conflict, add a link:
+        links: prevDepGraph.links.concat(
+          ...delta.getTypedDependencies().map(([dep,depSummary]) => ({source: fullDeltaId(delta), label: depSummary, color: 'black', target: fullDeltaId(dep), obj: null})),
+          ...delta.getConflicts().map(conflictingDelta => ({source: fullDeltaId(delta), label: "", color: 'DarkGoldenRod', bidirectional: true, target: fullDeltaId(conflictingDelta), obj: null})),
+        ),
       };
     });
+    highlightCurrentDeltas();
+  };
+
+  const highlightCurrentDeltas = () => {
+    // const deltas = [...version];
+    // setDependencyGraph(prevDepGraph => {
+    //   const nodes = prevDepGraph.nodes.map(node => {
+    //     const {id, highlight: oldHighlight, ...rest} = node;
+    //     const highlight = deltas.some(d => fullDeltaId(d) === id);
+    //     return {
+    //       id,
+    //       highlight,
+    //       ...rest,
+    //     };
+    //     // node.highlight = deltas.some(d => fullDeltaId(d) === node.id);
+    //     // return node;
+    //   });
+    //   return {
+    //     nodes,
+    //     links: prevDepGraph.links,
+    //     // .map(link => {
+    //     //   const {source, target, ...rest} = link;
+    //     //   return {
+    //     //     source: nodes.find(n => n.id === source.id),
+    //     //     target: nodes.find(n => n.id === target.id),
+    //     //     ...rest,
+    //     //   };
+    //     // }),
+    //   };
+    // });
   }
 
   // "physics" stuff (for graph layout)
-  // const editableGraphForces = {charge: -10, center:0.01, link:0.05};
   const historyGraphForces = {charge: -100, center:0.1, link:2};
   const depGraphForces = {charge: -200, center:0.1, link:0.2};
 
-  const historyMouseUpHandler = (event, {x,y}, node: d3Types.d3Node<Version> | undefined) => {
-    if (node !== undefined) {
-      // the user clicked on a version
-      const versionClicked = node.obj;
-      const deltas = [...versionClicked].reverse(); // all deltas of versionClicked, from early to late.
-
-      function execDelta(d: Delta) {
-        if (d instanceof CompositeDelta) {
-          d.deltas.forEach(execDelta);
-        }
-        else if (d instanceof NodeCreation) {
-          graphDeltaExecutor.execNodeCreation(d);
-        }
-        else if (d instanceof NodeDeletion) {
-          graphDeltaExecutor.execNodeDeletion(d);
-        }
-        else if (d instanceof EdgeCreation) {
-          graphDeltaExecutor.execEdgeCreation(d);
-        }
-        else if (d instanceof EdgeUpdate) {
-          graphDeltaExecutor.execEdgeUpdate(d);
-        }
-      }
+  const exec = (d) => {
+    manipulator.x = 0;
+    manipulator.y = 0;
 
-      manipulator.reset();
-      deltas.forEach(execDelta);
-      setVersion(versionClicked);
+    if (d instanceof CompositeDelta) {
+      d.deltas.forEach(exec);
+    }
+    else if (d instanceof NodeCreation) {
+      graphDeltaExecutor.execNodeCreation(d);
+    }
+    else if (d instanceof NodeDeletion) {
+      graphDeltaExecutor.execNodeDeletion(d);
+    }
+    else if (d instanceof EdgeCreation) {
+      graphDeltaExecutor.execEdgeCreation(d);
+    }
+    else if (d instanceof EdgeUpdate) {
+      graphDeltaExecutor.execEdgeUpdate(d);
+    }
+    else {
+      throw new Error("Assertion failed: Unexpected delta type");
     }
   }
+  const unexec = (d) => {
+    manipulator.x = 0;
+    manipulator.y = 0;
+
+    if (d instanceof CompositeDelta) {
+      [...d.deltas].reverse().forEach(unexec);
+    }
+    else if (d instanceof NodeCreation) {
+      graphDeltaExecutor.unexecNodeCreation(d);
+    }
+    else if (d instanceof NodeDeletion) {
+      graphDeltaExecutor.unexecNodeDeletion(d);
+    }
+    else if (d instanceof EdgeCreation) {
+      graphDeltaExecutor.unexecEdgeCreation(d);
+    }
+    else if (d instanceof EdgeUpdate) {
+      graphDeltaExecutor.unexecEdgeUpdate(d);
+    }
+    else {
+      throw new Error("Assertion failed: Unexpected delta type");
+    }
+  }
+
+  const historyMouseUpHandler = (event, {x,y}, node: d3Types.d3Node<Version> | undefined) => {
+  //   if (node !== undefined) {
+  //     // the user clicked on a version -> the clicked version becomes the "current version"
+  //     const versionClicked = node.obj;
+  //     const deltas = [...versionClicked].reverse(); // all deltas of versionClicked, from early to late.
+
+
+  //     let graph2 = _.cloneDeep(emptyGraph);
+  //     const setGraph2 = (updateFunction) => {
+  //       graph2 = updateFunction(graph2);
+  //     }
+  //     const manipulator2 = new D3StateManipulator(setGraph2);
+  //     const executor2 = new GraphDeltaExecutor(manipulator2);
+
+
+  //     function execDelta(d: Delta) {
+  //       if (d instanceof CompositeDelta) {
+  //         d.deltas.forEach(execDelta);
+  //       }
+  //       else if (d instanceof NodeCreation) {
+  //         executor2.execNodeCreation(d);
+  //       }
+  //       else if (d instanceof NodeDeletion) {
+  //         executor2.execNodeDeletion(d);
+  //       }
+  //       else if (d instanceof EdgeCreation) {
+  //         executor2.execEdgeCreation(d);
+  //       }
+  //       else if (d instanceof EdgeUpdate) {
+  //         executor2.execEdgeUpdate(d);
+  //       }
+  //     }
+  //     deltas.forEach(execDelta);
+
+  //     setGraph(prevGraph => {
+  //       const preservedLinks = prevGraph.links.filter(link => graph2.links.some(link2 => link.source === link2.source && link.label === link2.label));
+  //       const newLinks = graph2.links.filter(link2 => graph.links.every(link => !(link.source === link2.source && link.label === link2.label)));
+  //       newLinks.forEach(link => {
+  //         link.source = link
+  //       })
+  //       console.log("preservedLinks:", preservedLinks);
+  //       console.log("newLinks:", newLinks);
+  //       return {
+  //         nodes: prevGraph.nodes.filter(node => graph2.nodes.some(node2 => node.id === node2.id))
+  //           .concat(graph2.nodes.filter(node2 => graph.nodes.every(node => node.id !== node2.id))),
+  //         links: preservedLinks.concat(newLinks),
+  //       };
+  //     });
+
+  //     setVersion(versionClicked);
+  //     setHistoryGraph(prevHistoryGraph => updateCurrentVersionLink(prevHistoryGraph, versionClicked));
+  //   }
+  }
+
+  const onUndo = (parentVersion, deltaToUndo) => {
+    unexec(deltaToUndo);
+    setVersion(parentVersion);
+    highlightCurrentDeltas();
+    setHistoryGraph(prevHistoryGraph => updateCurrentVersionLink(prevHistoryGraph, parentVersion));
+  }
+
+  const onRedo = (childVersion, deltaToRedo) => {
+    exec(deltaToRedo);
+    setVersion(childVersion);
+    highlightCurrentDeltas();
+    setHistoryGraph(prevHistoryGraph => updateCurrentVersionLink(prevHistoryGraph, childVersion));
+  }
+
+  const undoButtons = version.parents.map(([parentVersion,deltaToUndo]) => {
+    return (
+      <div key={fullVersionId(parentVersion)}>
+        <Button fullWidth={true} compact={true} leftIcon={<IconPlayerTrackPrev size={18}/>} onClick={onUndo.bind(null, parentVersion,deltaToUndo)}>
+          UNDO {deltaToUndo.getDescription()}
+        </Button>
+        <Space h="xs"/>
+      </div>
+    );
+  });
+  const redoButtons = version.children.map(([childVersion,deltaToRedo]) => {
+    return (
+      <div key={fullVersionId(childVersion)}>
+        <Button style={{width: "100%"}} compact={true} rightIcon={<IconPlayerTrackNext size={18}/>} onClick={onRedo.bind(null, childVersion,deltaToRedo)}>
+          REDO {deltaToRedo.getDescription()}
+        </Button>
+        <Space h="xs"/>
+      </div>
+    );
+  });
 
   return (
     <Grid grow>
@@ -208,7 +384,7 @@ export function Branch(props: BranchProps) {
           graph={graph}
           graphDeltaExecutor={graphDeltaExecutor}
           forces={historyGraphForces}
-          getUuid={props.getUuid}
+          generateUUID={props.generateUUID}
           setNextNodePosition={(x,y)=>{manipulator.x = x; manipulator.y = y;}}
           newVersionHandler={newVersionHandler}
           newDeltaHandler={newDeltaHandler}
@@ -222,6 +398,14 @@ export function Branch(props: BranchProps) {
       <Grid.Col span={1}>
         <Title order={4}>History Graph</Title>
         <Graph graph={historyGraph} forces={historyGraphForces} mouseDownHandler={()=>{}} mouseUpHandler={historyMouseUpHandler} />
+        <SimpleGrid cols={2}>
+            <div>
+              {undoButtons}
+            </div>
+            <div>
+              {redoButtons}
+            </div>
+        </SimpleGrid>
         <Text>All links are parent links.</Text>
         <Text>Right or middle mouse button: Load version.</Text>
       </Grid.Col>
@@ -234,12 +418,12 @@ export function Branch(props: BranchProps) {
 }
 
 export function App() {
-  const getUuid = mockUuid();
+  const generateUUID = mockUuid();
 
   return (
     <Stack>
       <Title order={2}>Onion VCS Demo</Title>
-      <Branch getUuid={getUuid} />
+      <Branch generateUUID={generateUUID} />
       <RountangleEditor />
     </Stack>
   );

+ 2 - 2
src/frontend/editable_graph.tsx

@@ -33,7 +33,7 @@ interface EditableGraphProps {
   graphDeltaExecutor: GraphDeltaExecutor;
   forces: Forces;
   version: Version;
-  getUuid: () => UUID;
+  generateUUID: () => UUID;
   newVersionHandler: (Version) => void;
   newDeltaHandler: (CompositeDelta) => void;
 
@@ -72,7 +72,7 @@ export class EditableGraph extends React.Component<EditableGraphProps, EditableG
         }
         else {
           // right mouse button clicked -> create node
-          const uuid = this.props.getUuid();
+          const uuid = this.props.generateUUID();
           this.props.setNextNodePosition(x,y);
           return [new NodeCreation(uuid)];
         }

+ 22 - 9
src/frontend/graph.tsx

@@ -12,12 +12,15 @@ export namespace d3Types {
     obj: NodeType;
     x?: number,
     y?: number,
+    highlight: boolean,
   };
 
   export type d3Link<LinkType> = {
     source: any, // initially string, but d3 replaces it by a d3Node<NodeType> (lol)
     target: any, // initially string, but d3 replaces it by a d3Node<NodeType> (lol)
-    label: string
+    label: string,
+    color: string,
+    bidirectional?: boolean,
     obj: LinkType;
   };
 
@@ -51,10 +54,16 @@ class Link<LinkType> extends React.Component<{ link: d3Types.d3Link<LinkType> },
   }
 
   render() {
+    const textStyle = {
+      fill: this.props.link.color,
+    };
+    const arrowStyle = {
+      stroke: this.props.link.color,
+    }
     return (
       <g>
-        <line className="link" ref={this.ref} markerEnd="url(#arrow2)"/>
-        <text className="label" ref={this.refLabel}>{this.props.link.label}</text>
+        <line className="link" ref={this.ref} style={arrowStyle} markerEnd={this.props.link.bidirectional ? "" : "url(#arrowEnd)"}/>
+        <text className="label" ref={this.refLabel} style={textStyle}>{this.props.link.label}</text>
       </g>);
   }
 }
@@ -68,7 +77,7 @@ class Links<LinkType> extends React.Component<{ links: d3Types.d3Link<LinkType>[
 
   render() {
     const nodeId = sourceOrTarget => sourceOrTarget.id ? sourceOrTarget.id : sourceOrTarget;
-    const key = link => 's'+nodeId(link.source)+'t'+nodeId(link.target);
+    const key = link => nodeId(link.source)+nodeId(link.target)+link.label;
     const links = this.props.links.map((link: d3Types.d3Link<LinkType>, index: number) => {
       return <Link ref={link => this.links.push(link)} key={key(link)} link={link} />;
     });
@@ -140,6 +149,7 @@ class Node<NodeType> extends React.Component<NodeProps<NodeType>, {}> {
         onMouseUp={this.props.mouseUpHandler}
         r={5}
         fill={this.props.node.color}
+        style={{strokeWidth:this.props.node.highlight?"3px":"1px"}}
       >
         <title>{this.props.node.id}</title>
       </circle>
@@ -200,7 +210,7 @@ class Label<NodeType> extends React.Component<{ node: d3Types.d3Node<NodeType> }
   }
 
   render() {
-    return <text className="label" ref={this.ref}>
+    return <text className="label" ref={this.ref} fontWeight={this.props.node.highlight?"bold":"normal"}>
       {this.props.node.label}
     </text>;
   }
@@ -253,7 +263,7 @@ export class Graph<NodeType,LinkType> extends React.Component<GraphProps<NodeTyp
   constructor(props) {
     super(props);
     this.state = {
-      zoom: 1.0,
+      zoom: 1.5,
     };
     this.simulation = d3.forceSimulation()
       .force("link", d3.forceLink().id((d: any) => d.id).strength(props.forces.link))
@@ -289,11 +299,14 @@ export class Graph<NodeType,LinkType> extends React.Component<GraphProps<NodeTyp
         onMouseDown={e => clientToSvgCoords(e, this.props.mouseDownHandler)}
         onMouseUp={e => clientToSvgCoords(e, this.props.mouseUpHandler)}
         onContextMenu={e => e.preventDefault()}
-        onWheel={e => {this.setState(prevState => ({zoom: prevState.zoom - prevState.zoom * Math.min(Math.max(e.deltaY,-150), 150) / 200}))}}
+        onWheel={e => {this.setState(prevState => ({zoom: prevState.zoom - prevState.zoom * Math.min(Math.max(e.deltaY*0.5,-150), 150) / 200}))}}
       >
 
         <defs>
-          <marker id="arrow2" markerWidth="10" markerHeight="10" refX="14" refY="3" orient="auto" markerUnits="strokeWidth">
+          <marker id="arrowEnd" markerWidth="10" markerHeight="10" refX="14" refY="3" orient="auto" markerUnits="strokeWidth">
+            <path d="M0,0 L0,6 L9,3 z" className="arrowHead" />
+          </marker>
+          <marker id="arrowStart" markerWidth="10" markerHeight="10" refX="14" refY="3" orient="auto-start-reverse" markerUnits="strokeWidth">
             <path d="M0,0 L0,6 L9,3 z" className="arrowHead" />
           </marker>
         </defs>
@@ -332,7 +345,7 @@ export class Graph<NodeType,LinkType> extends React.Component<GraphProps<NodeTyp
 
   componentDidUpdate() {
     this.update();
-    this.simulation.alpha(0.3).restart().tick();
+    // this.simulation.alpha(0.3).restart().tick();
     this.ticked();
   }
 }

+ 3 - 2
src/onion/delta.ts

@@ -10,8 +10,8 @@ export interface Delta {
   getConflicts(): Array<Delta>;
 
   // Get an ID that is unique to the VALUE (attributes and dependencies) of this Delta.
-  // Meaning: if two deltas are identical, they have the same ID.
-  // Returned value must be 8 bytes (256 bits).
+  // Meaning: if two deltas are identical (they do exactly the same thing, and they have the exact same dependencies), they have the same ID.
+  // Returned value must be 8 bytes (256 bits). SHA-256 hashing is used internally.
   getHash(): Buffer;
 
   // A short text string that summarizes the delta.
@@ -23,6 +23,7 @@ export function isConflicting(a: Delta, b: Delta) {
   return a.getConflicts().includes(b);
 }
 
+// Yields all elements of 'it' that are conflicting with 'd'.
 export function* iterConflicts(d: Delta, it: Iterable<Delta>) {
   for (const conflictsWith of d.getConflicts()) {
     for (const otherDelta of it) {

+ 90 - 6
src/onion/delta_executor.ts

@@ -9,7 +9,8 @@ import {
 import {PrimitiveType} from "./types";
 
 
-// Captures the current state of a node.
+// In order to edit a graph, we must know what operations most recently "touched" every node, and every edge. This is because new edit operations can depend on earlier operations (that they overwrite).
+// This class captures, for a single node, a set of most-recent operations. It also has methods for editing the node. These methods are "pure" (they have no side-effects): they only return Deltas that capture the change. The change doesn't happen until those Deltas are (re)played, with GraphDeltaExecutor.
 export class NodeState {
   readonly creation: NodeCreation;
   readonly outgoing: Map<string, EdgeCreation|EdgeUpdate> = new Map();
@@ -63,7 +64,8 @@ export class NodeState {
   }
 }
 
-
+// This interface is used to de-couple the graph state (in our case, the EditableGraph React component) from GraphDeltaExecutor.
+// Maybe this is over-engineered a bit, because GraphDeltaExecutor is already de-coupled from primitive deltas through the PrimitiveDeltaExecutor interface.
 export interface GraphStateManipulator {
   createNode(ns: NodeState);
   deleteNode(id: PrimitiveType);
@@ -88,6 +90,10 @@ export class GraphDeltaExecutor implements PrimitiveDeltaExecutor {
     this.nodes.set(delta.id.value, nodeState);
     this.manipulator.createNode(nodeState);
   }
+  unexecNodeCreation(delta: NodeCreation) {
+    this.nodes.delete(delta.id.value);
+    this.manipulator.deleteNode(delta.id.value);
+  }
 
   execNodeDeletion(delta: NodeDeletion) {
     // console.log("execNodeDeletion", delta)
@@ -111,9 +117,33 @@ export class GraphDeltaExecutor implements PrimitiveDeltaExecutor {
         this.manipulator.deleteLink(sourceId, label);
       }
     }
-    this.nodes.delete(id);
+    // this.nodes.delete(id);
     this.manipulator.deleteNode(id);    
   }
+  unexecNodeDeletion(delta: NodeDeletion) {
+    // restore outgoing links
+    const id = delta.creation.id.value;
+    const nodeState = this.nodes.get(id);
+    if (nodeState === undefined) {
+      throw new Error("Assertion failed: deleted node does not exist")
+    }
+    this.manipulator.createNode(nodeState);
+    for (const outgoingEdgeOperation of nodeState.outgoing.values()) {
+      // For every outgoing edge of deleted node, restore in the target node the incoming edge operation by whatever was there before
+      if (outgoingEdgeOperation.target.target !== null) {
+        const targetId = outgoingEdgeOperation.target.target.id.value;
+        const targetState = this.nodes.get(targetId);
+        if (targetState === undefined) {
+          throw new Error("Assertion failed: Must have targetState.");
+        }
+        targetState.incoming.splice(targetState.incoming.findIndex((op) => op === outgoingEdgeOperation), 1, outgoingEdgeOperation);      
+        const outgoingEdgeCreation = outgoingEdgeOperation.getCreation();
+        const sourceId = outgoingEdgeCreation.source.id.value;
+        const label = outgoingEdgeCreation.label;
+        this.manipulator.createLink(sourceId, label, targetId);
+      }
+    }
+  }
 
   execEdgeCreation(delta: EdgeCreation) {
     // console.log("execEdgeCreation", delta)
@@ -135,6 +165,25 @@ export class GraphDeltaExecutor implements PrimitiveDeltaExecutor {
     targetState.incoming.push(delta);
     this.manipulator.createLink(sourceId, label, targetId);
   }
+  unexecEdgeCreation(delta: EdgeCreation) {
+    const sourceId = delta.source.id.value;
+    if (delta.target.target === null) {
+      throw new Error("Assertion failed: EdgeCreation never sets edge target to null.");
+    }
+    const targetId = delta.target.target.id.value;
+    const label = delta.label;
+    const sourceState = this.nodes.get(sourceId);
+    const targetState = this.nodes.get(targetId);
+    if (sourceState === undefined) {
+      throw new Error("Assertion failed: Source node is non-existing.")
+    }
+    if (targetState === undefined) {
+      throw new Error("Assertion failed: Target node is non-existing.");
+    }
+    sourceState.outgoing.delete(label);
+    targetState.incoming.splice(targetState.incoming.indexOf(delta), 1);
+    this.manipulator.deleteLink(sourceId, label);
+  }
 
   execEdgeUpdate(delta: EdgeUpdate) {
     // console.log("execEdgeUpdate", delta)
@@ -156,8 +205,7 @@ export class GraphDeltaExecutor implements PrimitiveDeltaExecutor {
       if (oldTargetState === undefined) {
         throw new Error("Assertion failed: Must have oldTargetState.")
       }
-      oldTargetState.incoming.splice(oldTargetState.incoming.findIndex((op)=>op===overwrittenEdgeOperation), 1);
-
+      oldTargetState.incoming.splice(oldTargetState.incoming.indexOf(overwrittenEdgeOperation), 1);
       this.manipulator.deleteLink(sourceId, label);
     }
     // Create link to new target (if there is a target)
@@ -170,9 +218,45 @@ export class GraphDeltaExecutor implements PrimitiveDeltaExecutor {
         throw new Error("Assertion failed: Must have newTargetState.");
       }
       newTargetState.incoming.push(delta);
-
       this.manipulator.createLink(sourceId, label, newTargetId);
     }
     sourceState.outgoing.set(label, delta);
   }
+  unexecEdgeUpdate(delta: EdgeUpdate) {
+    // console.log("execEdgeUpdate", delta)
+    // Delete link to old target
+    const edgeCreation = delta.getCreation();
+    const sourceId = edgeCreation.source.id.value;
+    const label = edgeCreation.label;
+    const overwrittenEdgeOperation = delta.overwrites;
+    const sourceState = this.nodes.get(sourceId);
+    if (sourceState === undefined) {
+      throw new Error("Assertion failed: Must have sourceState.");
+    }
+    // Delete link to new target (if there is a target)
+    if (delta.target.target !== null) {
+      // The new target is a node
+      const newTargetId = delta.target.target.id.value;
+      const newTargetState = this.nodes.get(newTargetId);
+      // Add to new target's incoming edges
+      if (newTargetState === undefined) {
+        throw new Error("Assertion failed: Must have newTargetState.");
+      }
+      newTargetState.incoming.splice(newTargetState.incoming.indexOf(delta), 1);
+      this.manipulator.deleteLink(sourceId, label);
+    }
+    // Restore link to old target
+    if (overwrittenEdgeOperation.target.target !== null) {
+      // The old target was a node
+      const oldTargetId = overwrittenEdgeOperation.target.target.id.value;
+      const oldTargetState = this.nodes.get(oldTargetId);
+      // Delete from old target's incoming edges:
+      if (oldTargetState === undefined) {
+        throw new Error("Assertion failed: Must have oldTargetState.")
+      }
+      oldTargetState.incoming.push(overwrittenEdgeOperation);
+      this.manipulator.createLink(sourceId, label, oldTargetId);
+    }
+    sourceState.outgoing.set(label, overwrittenEdgeOperation);
+  }
 }

+ 19 - 1
src/onion/primitive_delta.ts

@@ -12,10 +12,16 @@ export interface PrimitiveDeltaExecutor {
   execNodeDeletion(delta: NodeDeletion);
   execEdgeCreation(delta: EdgeCreation);
   execEdgeUpdate(delta: EdgeUpdate);
+
+  unexecNodeCreation(delta: NodeCreation);
+  unexecNodeDeletion(delta: NodeDeletion);
+  unexecEdgeCreation(delta: EdgeCreation);
+  unexecEdgeUpdate(delta: EdgeUpdate);
 }
 
 export interface PrimitiveDelta extends Delta {
-  exec(PrimitiveDeltaExecutor);
+  exec(executor: PrimitiveDeltaExecutor);
+  unexec(executor: PrimitiveDeltaExecutor);
 }
 
 export class NodeCreation implements PrimitiveDelta {
@@ -71,6 +77,9 @@ export class NodeCreation implements PrimitiveDelta {
   exec(executor: PrimitiveDeltaExecutor) {
     executor.execNodeCreation(this);
   }
+  unexec(executor: PrimitiveDeltaExecutor) {
+    executor.unexecNodeCreation(this);
+  }
 }
 
 export class NodeDeletion implements PrimitiveDelta {
@@ -266,6 +275,9 @@ export class NodeDeletion implements PrimitiveDelta {
   exec(executor: PrimitiveDeltaExecutor) {
     executor.execNodeDeletion(this);
   }
+  unexec(executor: PrimitiveDeltaExecutor) {
+    executor.unexecNodeDeletion(this);
+  }
 }
 
 // Common functionality in EdgeCreation and EdgeUpdate: both set the target of an edge, and this can conflict with the deletion of the target.
@@ -419,6 +431,9 @@ export class EdgeCreation implements PrimitiveDelta {
   exec(executor: PrimitiveDeltaExecutor) {
     executor.execEdgeCreation(this);
   }
+  unexec(executor: PrimitiveDeltaExecutor) {
+    executor.unexecEdgeCreation(this);
+  }
 }
 
 export class EdgeUpdate implements PrimitiveDelta {
@@ -509,6 +524,9 @@ export class EdgeUpdate implements PrimitiveDelta {
   exec(executor: PrimitiveDeltaExecutor) {
     executor.execEdgeUpdate(this);
   }
+  unexec(executor: PrimitiveDeltaExecutor) {
+    executor.unexecEdgeUpdate(this);
+  }
 
   *iterUpdates() {
     let current: EdgeUpdate | EdgeCreation = this;

+ 5 - 0
src/onion/version.ts

@@ -16,6 +16,7 @@ import {bufferXOR} from "./buffer_xor";
 // not exported -> use VersionRegistry to create versions
 export class Version {
   readonly parents: Array<[Version, Delta]>;
+  readonly children: Array<[Version, Delta]> = [];
 
   // Unique ID of the version - XOR of all delta hashes - guarantees that Versions with equal (unordered) sets of Deltas have the same ID.
   readonly hash: Buffer;
@@ -25,6 +26,9 @@ export class Version {
   // DO NOT USE constructor directly - instead use VersionRegistry.createVersion.
   constructor(parents: Array<[Version, Delta]>, hash: Buffer, size: number) {
     this.parents = parents;
+    for (const [parent,delta] of parents) {
+      parent.children.push([this, delta]);
+    }
     this.hash = hash;
     this.size = size;
   }
@@ -104,6 +108,7 @@ export class VersionRegistry {
     // Check pre-condition 2:
     const conflictsWith = iterConflicts(delta, parent).next().value;
     if (conflictsWith !== undefined) {
+      console.log(delta);
       throw new Error("Delta " + delta.toString() + " conflicts with " + conflictsWith.toString());
     }
     return this.createVersionUnsafe(parent, delta);