Is there a way to revert to a specific version?

Hello,

I am working on a collaborative drawing application and using yjs websocket server.
If I can, I want to add version control function (like google-docs, revert to previous version).

I checked out ‘y-prosemirror-versions’ example(Yjs Prosemirror Versions Example), but it seems like it is usable for text editor.

Is it possible reverting to previous specific version using snapshot or undoManager?

I’m trying to overwrite y-docs with stored snapshot, but I’m not sure if it works or not.

Thanks and regards
Rory

Hi @rory,

The API for reverting to old document state is not yet public. y-prosemirror uses hidden methods to read the previous state using a state-vector. It is my intention to provide an easy-to-use API based on this approach this year.

For now, maybe you can find another way around it. You could, for example, store the versioned state as a JSON object and restore the previous state by updating the Yjs state to the previous version. For drawing applications, it is probably not a problem to simply replace the complete state.

3 Likes

I recently implemented this and took a “git revert” approach rather than a “git reset; git push --force” approach. I created a new temporary document from the snapshot, instantiated an Y.UndoManager to track changes, then brought the snapshot up-to-date with the current document, then undid all those changes using the UndoManager, then brought the current document up-to-date with the temporary one.

This approach has the advantage that you don’t need to convince all the connected clients to suddenly reload the document from the server, it’s just treated as a regular update.

6 Likes

@wmhilton,

I’m trying to do a “git revert” exactly as you described and am investigating using the exact same methodology. Any chance you should share a gist of what you came up with? I’d prefer not to have to re-invent the wheel, if at all possible.

Thanks!
Sam

Haha, due to popular demand (and personal emails asking me “how the heck do I do this?”) the code I used is:

revertChangesSinceSnapshot(snapshot: string) {
  // this removes the leading `\x` from the snapshot string that was specific to our implementation
  const buff = toUint8Array(snapshot.replace(/^\\x/, ''));
  const snap = Y.decodeSnapshot(buff);
  const tempdoc = Y.createDocFromSnapshot(this.yDoc, snap);

  // We cannot simply replace `this.yDoc` because we have to sync with other clients.
  // Replacing `this.yDoc` would be similar to doing `git reset --hard $SNAPSHOT && git push --force`.
  // Instead, we compute an "anti-operation" of all the changes made since that snapshot.
  // This ends up being similar to `git revert $SNAPSHOT..HEAD`.
  const currentStateVector = Y.encodeStateVector(this.yDoc);
  const snapshotStateVector = Y.encodeStateVector(tempdoc);

  // creating undo command encompassing all changes since taking snapshot
  const changesSinceSnapshotUpdate = Y.encodeStateAsUpdate(this.yDoc, snapshotStateVector);
  // In our specific implementation, everything we care about is in a single root Y.Map, which makes
  // it easy to track with a Y.UndoManager. To be honest, your mileage may vary if you don't know which root types need to be tracked
  const um = new Y.UndoManager(tempdoc.getMap(ROOT_YJS_MAP), { trackedOrigins: new Set([YJS_SNAPSHOT_ORIGIN]) });
  Y.applyUpdate(tempdoc, changesSinceSnapshotUpdate, YJS_SNAPSHOT_ORIGIN);
  um.undo();

  // applying undo command to this.ydoc
  const revertChangesSinceSnapshotUpdate = Y.encodeStateAsUpdate(tempdoc, currentStateVector);
  Y.applyUpdate(this.yDoc, revertChangesSinceSnapshotUpdate, YJS_SNAPSHOT_ORIGIN);
}

It won’t work exactly as-is because it was part of a class and some things like this.yDoc and YJS_SNAPSHOT_ORIGIN are defined outside the function, but you should be able to piece together how it works. I hope.

9 Likes

@wmhilton 's suggestion and code snippet is good for most case.

However, Snapshot only works when gc is false. This means you cannot revert the changes when some deletions have merged in yjs (just like squash in Git). Using yjs as a partial library that helps collaboration instead of making yjs the fundamental core of the data structure like our app AFFiNE, the Snapshot is good.

I’m our app. We use another approach to revert selected data and work when GC is enabled.

export function revertUpdate(
  doc: Doc,
  snapshotUpdate: Uint8Array,
  getMetadata: (key: string) => 'Text' | 'Map' | 'Array'
) {
  const snapshotDoc = new Doc();
  applyUpdate(snapshotDoc, snapshotUpdate, snapshotOrigin);

  const currentStateVector = encodeStateVector(doc);
  const snapshotStateVector = encodeStateVector(snapshotDoc);

  const changesSinceSnapshotUpdate = encodeStateAsUpdate(
    doc,
    snapshotStateVector
  );
  const undoManager = new UndoManager(
    [...snapshotDoc.share.keys()].map(key => {
      const type = getMetadata(key);
      if (type === 'Text') {
        return snapshotDoc.getText(key);
      } else if (type === 'Map') {
        return snapshotDoc.getMap(key);
      } else if (type === 'Array') {
        return snapshotDoc.getArray(key);
      }
      throw new Error('Unknown type');
    }),
    {
      trackedOrigins: new Set([snapshotOrigin]),
    }
  );
  applyUpdate(snapshotDoc, changesSinceSnapshotUpdate, snapshotOrigin);
  undoManager.undo();
  const revertChangesSinceSnapshotUpdate = encodeStateAsUpdate(
    snapshotDoc,
    currentStateVector
  );
  applyUpdate(doc, revertChangesSinceSnapshotUpdate, snapshotOrigin);
}

getMetadata is the function that returns what the AbstractType should be. In the yjs, all yjs data will be AbstractType eventually, and it doesn’t store the real type of it (which makes me feel weird).

Also you can see our test case for it

describe('milestone', () => {
  test('milestone', async () => {
    const doc = new Doc();
    const map = doc.getMap('map');
    const array = doc.getArray('array');
    map.set('1', 1);
    array.push([1]);
    await markMilestone('1', doc, 'test1');
    const milestones = await getMilestones('1');
    assertExists(milestones);
    expect(milestones).toBeDefined();
    expect(Object.keys(milestones).length).toBe(1);
    expect(milestones.test1).toBeInstanceOf(Uint8Array);
    const snapshot = new Doc();
    applyUpdate(snapshot, milestones.test1);
    {
      const map = snapshot.getMap('map');
      expect(map.get('1')).toBe(1);
    }
    map.set('1', 2);
    {
      const map = snapshot.getMap('map');
      expect(map.get('1')).toBe(1);
    }
    revertUpdate(doc, milestones.test1, key =>
      key === 'map' ? 'Map' : 'Array'
    );
    {
      const map = doc.getMap('map');
      expect(map.get('1')).toBe(1);
    }

    const fn = vi.fn(() => true);
    doc.gcFilter = fn;
    expect(fn).toBeCalledTimes(0);

    for (let i = 0; i < 1e5; i++) {
      map.set(`${i}`, i + 1);
    }
    for (let i = 0; i < 1e5; i++) {
      map.delete(`${i}`);
    }
    for (let i = 0; i < 1e5; i++) {
      map.set(`${i}`, i - 1);
    }

    expect(fn).toBeCalled();

    const doc2 = new Doc();
    applyUpdate(doc2, encodeStateAsUpdate(doc));

    revertUpdate(doc2, milestones.test1, key =>
      key === 'map' ? 'Map' : 'Array'
    );
    {
      const map = doc2.getMap('map');
      expect(map.get('1')).toBe(1);
    }
  });
});

Maybe we will publish our y-indexeddb provider that support revert the version and more metadata for each ydoc

1 Like

what is the ROOT_YJS_MAP and YJS_SNAPSHOT_ORIGIN? how to defined it?