Quellcode durchsuchen

Refactor EditableGraph: decoupled (re)playing of deltas from d3 stuff. The flow is now always (1) user action -> (2) creation of deltas -> (3) execution of deltas.

Joeri Exelmans vor 3 Jahren
Ursprung
Commit
2e59173ee9

+ 89 - 52
src/frontend/app.tsx

@@ -4,15 +4,74 @@ import * as _ from "lodash";
 import {Grid, Text, Title, Group, Stack} from "@mantine/core";
 
 import {d3Types, Graph} from "./graph"
-import {EditableGraph, GraphType, NodeType, LinkType, nodeCreationToD3} from "./editable_graph";
+import {EditableGraph, GraphType, NodeType, LinkType} from "./editable_graph";
+import {NodeState, GraphDeltaExecutor, GraphStateManipulator} from "../onion/delta_executor"; 
 
-import {UUID} from "../onion/types";
+import {PrimitiveType, UUID} from "../onion/types";
 import {mockUuid} from "../onion/test_helpers";
 import {Version, initialVersion} from "../onion/version";
 import {Delta} from "../onion/delta";
 import {CompositeDelta} from "../onion/composite_delta";
 import {NodeCreation, NodeDeletion, EdgeCreation, EdgeUpdate} from "../onion/primitive_delta";
 
+class D3StateManipulator implements GraphStateManipulator {
+  readonly setGraph: (cb: (prevGraph: GraphType) => GraphType) => void;
+
+  // SVG coordinates for newly created nodes
+  x: number = 0;
+  y: number = 0;
+
+  constructor(setGraph: (cb: (prevGraph: GraphType) => GraphType) => void) {
+    this.setGraph = setGraph;
+  }
+
+  createNode(ns: NodeState) {
+    this.setGraph(prevGraph => ({
+      nodes: [...prevGraph.nodes, {
+        id: ns.creation.id.value.toString(),
+        label: ns.creation.id.value.toString(),
+        x: this.x,
+        y: this.y,
+        color: "darkturquoise",
+        obj: ns,
+      }],
+      links: prevGraph.links,
+    }));
+  }
+
+  deleteNode(id: PrimitiveType) {
+    this.setGraph(prevGraph => ({
+      nodes: prevGraph.nodes.filter(n => n.obj.creation.id.value !== id),
+      links: prevGraph.links,
+    }));
+  }
+
+  createLink(sourceId: PrimitiveType, label: string, targetId: PrimitiveType) {
+    this.setGraph(prevGraph => ({
+      nodes: prevGraph.nodes,
+      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,
+        obj: null,
+      }],
+    }));
+  }
+
+  deleteLink(sourceId: PrimitiveType, label: string) {
+    this.setGraph(prevGraph => ({
+      nodes: prevGraph.nodes,
+      links: prevGraph.links.filter(l => l.source.obj.creation.id.value !== sourceId || l.label !== label),
+    }));
+  }
+
+  reset() {
+    this.x = 0;
+    this.y = 0;
+    this.setGraph(prevGraph => _.cloneDeep(emptyGraph));
+  }
+}
+
 const emptyGraph = {
   nodes: [],
   links: [],
@@ -79,40 +138,11 @@ interface BranchProps {
 export function Branch(props: BranchProps) {
   const [version, setVersion] = React.useState<Version>(initialVersion); // the version currently being displayed in the Graph State (on the left)
   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));
 
-  // Callbacks for our EditableGraph component:
-  const createNode = (node: NodeType) => {
-    setGraph((prevState: GraphType) => ({
-      nodes: [...prevState.nodes, node],
-      links: prevState.links,
-    }));
-  }
-  const deleteNode = (id: string) => {
-    setGraph((prevState: GraphType) => {
-      const newLinks = prevState.links.filter(l => l.source.id !== id && l.target.id !== id);
-      return {
-        nodes: prevState.nodes.filter(n => n.id !== id),
-        links: newLinks,
-      };
-    });
-  };
-  const createLink = ({source, label, target, obj}: LinkType) => {
-    setGraph((prevState: GraphType) => ({
-      nodes: prevState.nodes,
-      links: [...prevState.links, {source, target, label, obj}],
-    }));
-  };
-  const deleteLink = (source: NodeType, label: string) => {
-    setGraph((prevState: GraphType) => {
-      const newLinks = prevState.links.filter(l => l.source !== source || l.label !== label)
-      return {
-        nodes: prevState.nodes,
-        links: newLinks,
-      };
-    });
-  };
   const newVersionHandler = (version: Version) => {
     setHistoryGraph(prevHistoryGraph => {
       return {
@@ -133,37 +163,36 @@ export function Branch(props: BranchProps) {
   }
 
   // "physics" stuff (for graph layout)
-  const editableGraphForces = {charge: -10, center:0.01, link:0.05};
+  // 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 graphState = _.cloneDeep(emptyGraph);
       const deltas = [...versionClicked].reverse(); // all deltas of versionClicked, from early to late.
-      function execDelta(delta) {
-        // just update graphState, in-place.
-        if (delta instanceof NodeCreation) {
-          graphState.nodes.push(nodeCreationToD3(delta, 0, 0));
+
+      function execDelta(d: Delta) {
+        if (d instanceof CompositeDelta) {
+          d.deltas.forEach(execDelta);
         }
-        else if (delta instanceof NodeDeletion) {
-          graphState.nodes.splice(graphState.nodes.findIndex((node: NodeType) => node.id === delta.creation.id.toString()), 1);
+        else if (d instanceof NodeCreation) {
+          graphDeltaExecutor.execNodeCreation(d);
         }
-        else if (delta instanceof EdgeCreation) {
-          // graphState.links.push()
-          // ?
+        else if (d instanceof NodeDeletion) {
+          graphDeltaExecutor.execNodeDeletion(d);
         }
-        else if (delta instanceof CompositeDelta) {
-          delta.deltas.forEach(execDelta);
+        else if (d instanceof EdgeCreation) {
+          graphDeltaExecutor.execEdgeCreation(d);
+        }
+        else if (d instanceof EdgeUpdate) {
+          graphDeltaExecutor.execEdgeUpdate(d);
         }
       }
+
+      manipulator.reset();
       deltas.forEach(execDelta);
-      console.log(graphState);
-      setGraph(graphState);
       setVersion(versionClicked);
     }
   }
@@ -174,9 +203,16 @@ export function Branch(props: BranchProps) {
         <Group>
           <Title order={4}>Graph state</Title>
         </Group>
-        <EditableGraph graph={graph} forces={editableGraphForces} getUuid={props.getUuid} newVersionHandler={newVersionHandler} newDeltaHandler={newDeltaHandler}
+        <EditableGraph
+          graph={graph}
+          graphDeltaExecutor={graphDeltaExecutor}
+          forces={historyGraphForces}
+          getUuid={props.getUuid}
+          setNextNodePosition={(x,y)=>{manipulator.x = x; manipulator.y = y;}}
+          newVersionHandler={newVersionHandler}
+          newDeltaHandler={newDeltaHandler}
           version={version}
-          createNode={createNode} deleteNode={deleteNode} createLink={createLink} deleteLink={deleteLink} />
+        />
         <Text>Left mouse button: Drag node around.</Text>
         <Text>Middle mouse button: Delete node (+ incoming/outgoing edges).</Text>
         <Text>Right mouse button: Create node or edge.</Text>
@@ -186,6 +222,7 @@ export function Branch(props: BranchProps) {
         <Title order={4}>History Graph</Title>
         <Graph graph={historyGraph} forces={historyGraphForces} mouseDownHandler={()=>{}} mouseUpHandler={historyMouseUpHandler} />
         <Text>All links are parent links.</Text>
+        <Text>Right or middle mouse button: Load version.</Text>
       </Grid.Col>
       <Grid.Col span={1}>
         <Title order={4}>Dependency Graph (L1)</Title>

+ 40 - 146
src/frontend/editable_graph.tsx

@@ -6,14 +6,16 @@ import {
   NodeDeletion,
   EdgeCreation,
   EdgeUpdate,
+  PrimitiveDelta,
 } from "../onion/primitive_delta";
 
+import {NodeState, GraphDeltaExecutor} from "../onion/delta_executor";
+
 import {
-  // CompositeDelta,
   CompositeLevel,
 } from "../onion/composite_delta";
 
-import {UUID} from "../onion/types";
+import {PrimitiveType, UUID} from "../onion/types";
 
 import {
   Version,
@@ -21,196 +23,88 @@ import {
   VersionRegistry,
 } from "../onion/version";
 
-// The stuff that we store in each (visible) node:
-interface NodeObjType {
-  // The delta that created the node
-  nodeCreation: NodeCreation;
-
-  // Outgoing edges: Mapping from edge label to the most recent EdgeCreation or EdgeUpdate.
-  outgoing: Map<string, [EdgeCreation|EdgeUpdate, NodeObjType]>;
-
-  // For any edge that is or was incoming, we keep the delta that set, unset or deleted that edge. If this node is deleted, this deletion must depend on those deltas.
-  incoming: Array<[EdgeCreation|EdgeUpdate|NodeDeletion, NodeObjType]>;
-}
-
-type EdgeObjType = null; // We don't need to store anything special in edges.
+export type NodeType = d3Types.d3Node<NodeState>;
+export type LinkType = d3Types.d3Link<null>;
 
-export type NodeType = d3Types.d3Node<NodeObjType>;
-export type LinkType = d3Types.d3Link<EdgeObjType>;
-
-export type GraphType = d3Types.d3Graph<NodeObjType,EdgeObjType>;
+export type GraphType = d3Types.d3Graph<NodeState,null>;
 
 interface EditableGraphProps {
   graph: GraphType;
+  graphDeltaExecutor: GraphDeltaExecutor;
   forces: Forces;
   version: Version;
   getUuid: () => UUID;
   newVersionHandler: (Version) => void;
-  newDeltaHandler: (Delta) => void;
+  newDeltaHandler: (CompositeDelta) => void;
 
-  createNode: (NodeType) => void;
-  deleteNode: (string) => void;
-  createLink: (LinkType) => void;
-  deleteLink: (source: NodeType, label:string) => void;
+  setNextNodePosition: (x:number, y:number) => void;
 }
 
 interface EditableGraphState {
 }
 
-export function nodeCreationToD3(nodeCreation: NodeCreation, x: number, y: number): NodeType {
-  return {
-    id: nodeCreation.id.value.toString(),
-    label: nodeCreation.id.value.toString(),
-    x, y,
-    color: "darkturquoise",
-    obj: {nodeCreation, outgoing: new Map(), incoming: []},
-  };
-}
 
 export class EditableGraph extends React.Component<EditableGraphProps, EditableGraphState> {
-  graphRef: React.RefObject<Graph<NodeObjType,null>> = React.createRef<Graph<NodeObjType,null>>();
-  mouseDownNode: d3Types.d3Node<NodeObjType> | null = null; // only while mouse button is down, does this record the d3Node that was under the cursor when the mouse went down.
-
-  nextId: number = 0;
+  mouseDownNode: NodeType | null = null; // only while mouse button is down, does this record the d3Node that was under the cursor when the mouse went down.
 
   readonly compositeLvl: CompositeLevel = new CompositeLevel();
   readonly versionRegistry: VersionRegistry = new VersionRegistry();
-  // currentVersion: Version;
-
-  // constructor(props) {
-  //   super(props);
-  //   // this.currentVersion = initialVersion;
-  // }
 
-
-  mouseDownHandler = (event, {x,y}, node) => {
+  mouseDownHandler = (event, {x,y}, node: NodeType | undefined) => {
     event.stopPropagation();
-    // console.log("DOWN:", node, event);
     if (node) {
       this.mouseDownNode = node;
     }
   }
 
-  mouseUpHandler = (event, {x,y}, node: d3Types.d3Node<NodeObjType> | undefined) => {
+  mouseUpHandler = (event, {x,y}, node: NodeType | undefined) => {
     event.stopPropagation();
-    // console.log("UP:", node, event);
-    if (this.graphRef.current !== null) {
-      if (event.button === 2)  { // right mouse button
+
+    // Construct the delta(s) that capture the user's change:
+    const deltas: PrimitiveDelta[] = (() => {
+      if (event.button === 2) { // right mouse button
         if (node !== undefined && this.mouseDownNode !== null) {
+          // right mouse button was dragged from one node to another -> create/update edge
           const label = prompt("Edge label:", "label");
           if (label !== null) {
-            // Create or update edge:
-            const source = this.mouseDownNode.obj;
-            const target = node.obj;
-            const previousEdgeUpdate = source.outgoing.get(label);
-            const edgeCreationOrUpdate = (() => {
-              if (previousEdgeUpdate !== undefined) {
-                console.log("updating existing edge...")
-                const [prevUpdate, prevTargetObj] = previousEdgeUpdate;
-                // an edge with the same source and label already exists:
-                const edgeUpdate = new EdgeUpdate(prevUpdate, target.nodeCreation);
-                this.props.deleteLink(this.mouseDownNode, label);
-                // replace item in array:
-                console.log("replace item in array:");
-                prevTargetObj.incoming.splice(target.incoming.findIndex(([delta, _])=>delta===prevUpdate), 1, [edgeUpdate, prevTargetObj]);
-                console.log("prevTargetObj:", prevTargetObj);
-                return edgeUpdate;
-              }
-              else {
-                const edgeCreation = new EdgeCreation(source.nodeCreation, label, target.nodeCreation);
-                return edgeCreation;
-              }
-            })();
-            source.outgoing.set(label, [edgeCreationOrUpdate, target]);
-            target.incoming.push([edgeCreationOrUpdate, source]);
-            console.log("target:", target);
-
-            // console.log("target.incoming:", target.incoming); 
-            // console.log("edgeCreationOrUpdate.conflicts:", edgeCreationOrUpdate.getConflicts());
-
-            this.props.createLink({source: this.mouseDownNode, label, target: node, obj: null});
-
-            const tx = this.compositeLvl.createComposite([edgeCreationOrUpdate]);
-            const version = this.versionRegistry.createVersion(this.props.version, tx);
-            this.props.newDeltaHandler(tx);
-            this.props.newVersionHandler(version);
+            return [this.mouseDownNode.obj.setEdge(label, node.obj)];
           }
         }
-        else { // right mouse button
-          // Create node:
+        else {
+          // right mouse button clicked -> create node
           const uuid = this.props.getUuid();
-          const nodeCreation = new NodeCreation(uuid);
-
-          console.log("createNode??")
-          this.props.createNode(nodeCreationToD3(nodeCreation, x, y));
-
-          const tx = this.compositeLvl.createComposite([nodeCreation]);
-          const version = this.versionRegistry.createVersion(this.props.version, tx);
-          this.props.newDeltaHandler(tx);
-          this.props.newVersionHandler(version);
+          this.props.setNextNodePosition(x,y);
+          return [new NodeCreation(uuid)];
         }
       }
       else if (event.button === 1) { // middle mouse button
-        if (node) {
-          // Delete node (and its incoming + outgoing edges):
-          // console.log("incoming:", node.obj.incoming);
-          // console.log("outgoing:", node.obj.outgoing);
-
-          const outgoingEdgeDependencies: any[] = [];
-          const incomingEdgeDependencies: any[] = [];
-          const compositeDeltas: any[] = [];
-
-          for (const [incomingEdge,sourceObj] of node.obj.incoming) {
-            // We also depend on incoming edges that were deleted (because their source node was deleted):
-            if (incomingEdge instanceof NodeDeletion) {
-              console.log("edge was already deleted");
-              incomingEdgeDependencies.push(incomingEdge);
-            }
-            else {
-              if (incomingEdge.target.target === node.obj.nodeCreation) {
-                console.log("unsetting incoming edge...");
-                // Must set the value of every incoming edge to 'null' (with an EdgeUpdate):
-                const edgeUnset = new EdgeUpdate(incomingEdge, null);
-                sourceObj.outgoing.set(incomingEdge.getCreation().label, [edgeUnset,node.obj]);
-                incomingEdgeDependencies.push(edgeUnset);
-                compositeDeltas.push(edgeUnset);
-              }
-              else {
-                console.log("edge already pointing somewhere else");
-                // Edge is already pointing somewhere else: just include the operation as a dependency for the deletion.
-                incomingEdgeDependencies.push(incomingEdge);
-              }
-            }
-          }
-          const targetIncomings: Array<[EdgeCreation|EdgeUpdate|NodeDeletion, NodeObjType]>[] = [];
-          for (const [outgoingEdge, targetObj] of node.obj.outgoing.values()) {
-            targetObj.incoming.splice(targetObj.incoming.findIndex(([d,_])=>d===outgoingEdge), 1);
-            targetIncomings.push(targetObj.incoming);
-            outgoingEdgeDependencies.push(outgoingEdge);
-          }
-          const nodeDeletion = new NodeDeletion(node.obj.nodeCreation, outgoingEdgeDependencies, incomingEdgeDependencies);
-          compositeDeltas.push(nodeDeletion);
-          for (const targetIncoming of targetIncomings) {
-            targetIncoming.push([nodeDeletion, node.obj]);
-          }
-          this.props.deleteNode(node.id);
-          // console.log("deleteDependencies:", deleteDependencies);
-          // console.log("compositeDeltas:", compositeDeltas);
-          const tx = this.compositeLvl.createComposite(compositeDeltas);
-          const version = this.versionRegistry.createVersion(this.props.version, tx);
-          this.props.newDeltaHandler(tx);
-          this.props.newVersionHandler(version);
+        if (node !== undefined) {
+          // middle mouse button click on node -> delete node (and incoming/outgoing edges)
+          return node.obj.delete();
         }
       }
+      return [];
+    })();
+
+    if (deltas.length > 0) {
+      // Let the world know that there is a new (composite) delta, and a new version:
+      const composite = this.compositeLvl.createComposite(deltas);
+      const version = this.versionRegistry.createVersion(this.props.version, composite);
+      this.props.newDeltaHandler(composite);
+      this.props.newVersionHandler(version);
+
+      // Actually update the graph state:
+      deltas.forEach(d => d.exec(this.props.graphDeltaExecutor));      
     }
+
     this.mouseDownNode = null;
   }
 
+
   render() {
     return (
       <>
         <Graph
-          ref={this.graphRef}
           graph={this.props.graph}
           forces={this.props.forces}
           mouseDownHandler={this.mouseDownHandler}

+ 178 - 0
src/onion/delta_executor.ts

@@ -0,0 +1,178 @@
+import {
+  NodeCreation,
+  NodeDeletion,
+  EdgeCreation,
+  EdgeUpdate,
+  PrimitiveDeltaExecutor,
+} from "./primitive_delta";
+
+import {PrimitiveType} from "./types";
+
+
+// Captures the current state of a node.
+export class NodeState {
+  readonly creation: NodeCreation;
+  readonly outgoing: Map<string, EdgeCreation|EdgeUpdate> = new Map();
+  readonly incoming: Array<EdgeCreation|EdgeUpdate|NodeDeletion> = [];
+
+  constructor(creation: NodeCreation) {
+    this.creation = creation;
+  }
+
+  // Has no side effects - instead returns the deltas that capture the creation or update of the given outgoing edge
+  setEdge(label: string, targetState: NodeState): EdgeCreation|EdgeUpdate {
+    const previousEdgeUpdate = this.outgoing.get(label);
+    if (previousEdgeUpdate !== undefined) {
+      return new EdgeUpdate(previousEdgeUpdate, targetState.creation);
+    }
+    else {
+      return new EdgeCreation(this.creation, label, targetState.creation);
+    }
+  }
+
+  // Has no side effects - instead returns the deltas that capture the deletion of this node (and its incoming+outgoing edges)
+  delete(): (EdgeUpdate|NodeDeletion)[] {
+    const edgeUnsettings: EdgeUpdate[] = [];
+    const incomingEdgeDependencies = this.incoming.map((incomingEdge) => {
+      // We also depend on incoming edges that were deleted (because their source node was deleted):
+      if (incomingEdge instanceof NodeDeletion) {
+        // console.log("edge was already deleted");
+        return incomingEdge;
+      }
+      else if (incomingEdge.target.target === this.creation) {
+          // console.log("unsetting incoming edge...");
+          // Must set the value of every incoming edge to 'null' (with an EdgeUpdate):
+          const edgeUnsetting = new EdgeUpdate(incomingEdge, null);
+          edgeUnsettings.push(edgeUnsetting);
+          return edgeUnsetting;
+      }
+      else {
+        // console.log("edge already pointing somewhere else");
+        // Edge is already pointing somewhere else: just include the operation as a dependency for the deletion.
+        if (incomingEdge instanceof EdgeCreation) {
+          throw new Error("Assertion failed: incomingEdge must be EdgeUpdate here.")
+        }
+        return incomingEdge;
+      }
+    });
+
+    const outgoingEdgeDependencies = [...this.outgoing.values()];
+    const nodeDeletion = new NodeDeletion(this.creation, outgoingEdgeDependencies, incomingEdgeDependencies);
+
+    return [...edgeUnsettings, nodeDeletion];
+  }
+}
+
+
+export interface GraphStateManipulator {
+  createNode(ns: NodeState);
+  deleteNode(id: PrimitiveType);
+  createLink(sourceId: PrimitiveType, label: string, targetId: PrimitiveType);
+  deleteLink(sourceId: PrimitiveType, label: string);
+}
+
+
+// Executes (primitive) deltas, and updates the graph state accordingly (through GraphStateManipulator)
+// Decouples execution of deltas from any specific graph state representation (e.g. d3).
+export class GraphDeltaExecutor implements PrimitiveDeltaExecutor {
+  readonly nodes: Map<PrimitiveType, NodeState> = new Map();
+  readonly manipulator: GraphStateManipulator;
+
+  constructor(manipulator: GraphStateManipulator) {
+    this.manipulator = manipulator;
+  }
+
+  execNodeCreation(delta: NodeCreation) {
+    // console.log("execNodeCreation", delta)
+    const nodeState = new NodeState(delta);
+    this.nodes.set(delta.id.value, nodeState);
+    this.manipulator.createNode(nodeState);
+  }
+
+  execNodeDeletion(delta: NodeDeletion) {
+    // console.log("execNodeDeletion", delta)
+    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")
+    }
+    for (const outgoingEdgeOperation of nodeState.outgoing.values()) {
+      // For every outgoing edge of deleted node, replace in the target node the incoming edge operation by the deletion:
+      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, delta);      
+        const outgoingEdgeCreation = outgoingEdgeOperation.getCreation();
+        const sourceId = outgoingEdgeCreation.source.id.value;
+        const label = outgoingEdgeCreation.label;
+        this.manipulator.deleteLink(sourceId, label);
+      }
+    }
+    this.nodes.delete(id);
+    this.manipulator.deleteNode(id);    
+  }
+
+  execEdgeCreation(delta: EdgeCreation) {
+    // console.log("execEdgeCreation", delta)
+    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.set(label, delta);
+    targetState.incoming.push(delta);
+    this.manipulator.createLink(sourceId, label, targetId);
+  }
+
+  execEdgeUpdate(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 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.splice(oldTargetState.incoming.findIndex((op)=>op===overwrittenEdgeOperation), 1);
+
+      this.manipulator.deleteLink(sourceId, label);
+    }
+    // Create 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.push(delta);
+
+      this.manipulator.createLink(sourceId, label, newTargetId);
+    }
+    sourceState.outgoing.set(label, delta);
+  }
+}

+ 2 - 2
src/onion/graph_state.test.ts

@@ -1,6 +1,6 @@
 import {GraphState} from "./graph_state"
-import {NodeId, PrimitiveType, UUID, nodeIdsEqual} from "./types";
-import {mockUuid, assert} from "./test_helpers";
+import {NodeId, PrimitiveType, UUID, nodeIdsEqual} from "../types";
+import {mockUuid, assert} from "../test_helpers";
 
 
 describe("CRUD operations", () => {

+ 1 - 1
src/onion/graph_state.ts

@@ -3,7 +3,7 @@ import {
   PrimitiveType,
   UUID,
   nodeIdsEqual,
-} from "./types";
+} from "../types";
 
 // In- and outgoing edges of a node.
 // This is the only place where edges are recorded.

+ 31 - 4
src/onion/primitive_delta.ts

@@ -7,7 +7,18 @@ import {UUID} from "./types";
 import {bufferXOR} from "./buffer_xor";
 import {Delta} from "./delta";
 
-export class NodeCreation implements Delta {
+export interface PrimitiveDeltaExecutor {
+  execNodeCreation(delta: NodeCreation);
+  execNodeDeletion(delta: NodeDeletion);
+  execEdgeCreation(delta: EdgeCreation);
+  execEdgeUpdate(delta: EdgeUpdate);
+}
+
+export interface PrimitiveDelta extends Delta {
+  exec(PrimitiveDeltaExecutor);
+}
+
+export class NodeCreation implements PrimitiveDelta {
   readonly id: UUID;
   readonly hash: Buffer;
 
@@ -56,9 +67,13 @@ export class NodeCreation implements Delta {
   toString(): string {
     return this[inspect.custom](0, {});
   }
+
+  exec(executor: PrimitiveDeltaExecutor) {
+    executor.execNodeCreation(this);
+  }
 }
 
-export class NodeDeletion implements Delta {
+export class NodeDeletion implements PrimitiveDelta {
   readonly hash: Buffer;
 
   // Dependency: The node being deleted.
@@ -247,6 +262,10 @@ export class NodeDeletion implements Delta {
   toString(): string {
     return this[inspect.custom](0, {});
   }
+
+  exec(executor: PrimitiveDeltaExecutor) {
+    executor.execNodeDeletion(this);
+  }
 }
 
 // Common functionality in EdgeCreation and EdgeUpdate: both set the target of an edge, and this can conflict with the deletion of the target.
@@ -292,7 +311,7 @@ class SetsTarget {
 //   overwrittenBy: Array<EdgeUpdate | NodeDeletion>; // any subsequent update, or deletion of its source node
 // }
 
-export class EdgeCreation implements Delta {
+export class EdgeCreation implements PrimitiveDelta {
   // Dependencies
   readonly source: NodeCreation;
   readonly label: string;
@@ -396,9 +415,13 @@ export class EdgeCreation implements Delta {
   toString(): string {
     return this[inspect.custom](0, {});
   }
+
+  exec(executor: PrimitiveDeltaExecutor) {
+    executor.execEdgeCreation(this);
+  }
 }
 
-export class EdgeUpdate implements Delta {
+export class EdgeUpdate implements PrimitiveDelta {
   // Dependencies
   readonly overwrites: EdgeCreation | EdgeUpdate;
   readonly target: SetsTarget;
@@ -483,6 +506,10 @@ export class EdgeUpdate implements Delta {
     return this[inspect.custom](0, {});
   }
 
+  exec(executor: PrimitiveDeltaExecutor) {
+    executor.execEdgeUpdate(this);
+  }
+
   *iterUpdates() {
     let current: EdgeUpdate | EdgeCreation = this;
     while (true) {