cleanupTransactions = (transactionCleanups, i) => {
  if (i < transactionCleanups.length) {
    const transaction = transactionCleanups[i];
    const doc = transaction.doc;
    const store = doc.store;
    const ds = transaction.deleteSet;
    const mergeStructs = transaction._mergeStructs;

    try {
      sortAndMergeDeleteSet(ds);
      transaction.afterState = getStateVector(transaction.doc.store);
      doc._transaction = null;
      doc.emit('beforeObserverCalls', [transaction, doc]);
      /**
       * An array of event callbacks.
       *
       * Each callback is called even if the other ones throw errors.
       *
       * @type {Array<function():void>}
       */

      const fs = []; // observe events on changed types

      transaction.changed.forEach((subs, itemtype) => fs.push(() => {
        if (itemtype._item === null || !itemtype._item.deleted) {
          itemtype._callObserver(transaction, subs);
        }
      }));
      fs.push(() => {
        // deep observe events
        transaction.changedParentTypes.forEach((events, type) => fs.push(() => {
          // We need to think about the possibility that the user transforms the
          // Y.Doc in the event.
          if (type._item === null || !type._item.deleted) {
            events = events.filter(event => event.target._item === null || !event.target._item.deleted);
            events.forEach(event => {
              event.currentTarget = type;
            }); // sort events by path length so that top-level events are fired first.

            events.sort((event1, event2) => event1.path.length - event2.path.length); // We don't need to check for events.length
            // because we know it has at least one element

            callEventHandlerListeners(type._dEH, events, transaction);
          }
        }));
        fs.push(() => doc.emit('afterTransaction', [transaction, doc]));
      });
      (0, f.callAll)(fs, []);
    } finally {
      // Replace deleted items with ItemDeleted / GC.
      // This is where content is actually remove from the Yjs Doc.
      if (doc.gc) {
        tryGcDeleteSet(ds, store, doc.gcFilter);
      }

      tryMergeDeleteSet(ds, store); // on all affected store.clients props, try to merge

      transaction.afterState.forEach((clock, client) => {
        const beforeClock = transaction.beforeState.get(client) || 0;

        if (beforeClock !== clock) {
          const structs =
          /** @type {Array<GC|Item>} */
          store.clients.get(client); // we iterate from right to left so we can safely remove entries

          const firstChangePos = math.max(findIndexSS(structs, beforeClock), 1);

          for (let i = structs.length - 1; i >= firstChangePos; i--) {
            tryToMergeWithLeft(structs, i);
          }
        }
      }); // try to merge mergeStructs
      // @todo: it makes more sense to transform mergeStructs to a DS, sort it, and merge from right to left
      //        but at the moment DS does not handle duplicates

      for (let i = 0; i < mergeStructs.length; i++) {
        const {
          client,
          clock
        } = mergeStructs[i].id;
        const structs =
        /** @type {Array<GC|Item>} */
        store.clients.get(client);
        const replacedStructPos = findIndexSS(structs, clock);

        if (replacedStructPos + 1 < structs.length) {
          tryToMergeWithLeft(structs, replacedStructPos + 1);
        }

        if (replacedStructPos > 0) {
          tryToMergeWithLeft(structs, replacedStructPos);
        }
      }

      if (!transaction.local && transaction.afterState.get(doc.clientID) !== transaction.beforeState.get(doc.clientID)) {
        doc.clientID = generateNewClientId();
        logging.print(logging.ORANGE, logging.BOLD, '[yjs] ', logging.UNBOLD, logging.RED, 'Changed the client-id because another client seems to be using it.');
      } // @todo Merge all the transactions into one and provide send the data as a single update message


      doc.emit('afterTransactionCleanup', [transaction, doc]);

      if (doc._observers.has('update')) {
        const encoder = new UpdateEncoderV1();
        const hasContent = writeUpdateMessageFromTransaction(encoder, transaction);

        if (hasContent) {
          doc.emit('update', [encoder.toUint8Array(), transaction.origin, doc, transaction]);
        }
      }

      if (doc._observers.has('updateV2')) {
        const encoder = new UpdateEncoderV2();
        const hasContent = writeUpdateMessageFromTransaction(encoder, transaction);

        if (hasContent) {
          doc.emit('updateV2', [encoder.toUint8Array(), transaction.origin, doc, transaction]);
        }
      }

      const {
        subdocsAdded,
        subdocsLoaded,
        subdocsRemoved
      } = transaction;

      if (subdocsAdded.size > 0 || subdocsRemoved.size > 0 || subdocsLoaded.size > 0) {
        subdocsAdded.forEach(subdoc => {
          subdoc.clientID = doc.clientID;

          if (subdoc.collectionid == null) {
            subdoc.collectionid = doc.collectionid;
          }

          doc.subdocs.add(subdoc);
        });
        subdocsRemoved.forEach(subdoc => doc.subdocs.delete(subdoc));
        doc.emit('subdocs', [{
          loaded: subdocsLoaded,
          added: subdocsAdded,
          removed: subdocsRemoved
        }, doc, transaction]);
        subdocsRemoved.forEach(subdoc => subdoc.destroy());
      }

      if (transactionCleanups.length <= i + 1) {
        doc._transactionCleanups = [];
        doc.emit('afterAllTransactions', [doc, transactionCleanups]);
      } else {
        cleanupTransactions(transactionCleanups, i + 1);
      }
    }
  }
}
@param {Array<Transaction>} transactionCleanups
@param {number} i