Snapshots, Syncing, and History

I am trying to write some unit tests to simulate or document lifecycle. I have these states I am trying:
1. Initial State
2. Sync with Server: Snapshot 1
3. Send to Client with Offline Edit
4. Out of Server Band Edit: Snapshot 2
5. Sync with Server: Snapshot 3
6. Revert to Snapshot 1
7. Revert to Snapshot 2
8. Revert to Snapshot 3

Here is my test code:

import { expect } from 'chai';
import * as Y from 'yjs';

describe('Full Stack Interaction', () => {
  describe.only('Snapshots', () => {
    const mapKey = 'Data';

    const creatorUserId = '123';
    const serviceUserId = '456';
    const clientUserId = '789';

    let initialState: Uint8Array;
    let serverState: Uint8Array;
    let clientState: Uint8Array;
    let neverGarbageCollectedState: Uint8Array;

    let snapshot1: Y.Snapshot;
    let snapshot2: Y.Snapshot;
    let snapshot3: Y.Snapshot;

    it('1. Initial State', () => {
      // Initialize
      const result = new Y.Doc();
      const permanentUserDataCreator = new Y.PermanentUserData(result);
      permanentUserDataCreator.setUserMapping(result, result.clientID, creatorUserId);

      // Edit
      const taskGroups = result.getMap(mapKey);
      const task1 = new Y.Map<string>([
        ['id', 'task_1'],
        ['label', 'I am Task 1'],
        ['sortWeight', 'M']
      ]);
      const task2 = new Y.Map<string>([
        ['id', 'task_2'],
        ['label', 'I am Task 2'],
        ['sortWeight', 'N']
      ]);
      taskGroups.set('task_1', task1);
      taskGroups.set('task_2', task2);

      // Save
      initialState = Y.encodeStateAsUpdate(result);

      // Assert
      expect(result.getMap(mapKey).toJSON()).to.deep.equal({
        task_1: { id: 'task_1', label: 'I am Task 1', sortWeight: 'M' },
        task_2: { id: 'task_2', label: 'I am Task 2', sortWeight: 'N' }
      });
    });

    it('2. Sync with Server: Snapshot 1', () => {
      // Initialize
      const serviceYDoc = new Y.Doc();
      Y.applyUpdate(serviceYDoc, initialState);

      // Save
      serverState = Y.encodeStateAsUpdate(serviceYDoc);

      // Save Snapshot
      const neverCollectedYDoc = new Y.Doc({ gc: false});
      Y.applyUpdate(neverCollectedYDoc, initialState);
      Y.applyUpdate(neverCollectedYDoc, serverState);
      snapshot1 = Y.snapshot(neverCollectedYDoc);
      neverGarbageCollectedState = Y.encodeStateAsUpdate(neverCollectedYDoc);

      // Assert
      expect(serviceYDoc.getMap(mapKey).toJSON()).to.deep.equal({
        task_1: { id: 'task_1', label: 'I am Task 1', sortWeight: 'M' },
        task_2: { id: 'task_2', label: 'I am Task 2', sortWeight: 'N' }
      });
      expect(serverState).to.deep.equal(initialState);
    });

    it('3. Send to Client with Offline Edit', () => {
      // Initialize
      const clientYDoc = new Y.Doc();
      const permanentUserDataConduit = new Y.PermanentUserData(clientYDoc);
      permanentUserDataConduit.setUserMapping(clientYDoc, clientYDoc.clientID, clientUserId);
      Y.applyUpdate(clientYDoc, serverState);

      // Edit
      (clientYDoc.getMap(mapKey).get('task_1')! as Y.Map<string>).set('label', 'Task 1 Label Updated');
      (clientYDoc.getMap(mapKey).get('task_2')! as Y.Map<string>).set('label', 'Task 2 Label Updated');

      // Save
      clientState = Y.encodeStateAsUpdate(clientYDoc);

      // Assert
      expect(clientYDoc.getMap(mapKey).toJSON()).to.deep.equal({
        task_1: { id: 'task_1', label: 'Task 1 Label Updated', sortWeight: 'M' },
        task_2: { id: 'task_2', label: 'Task 2 Label Updated', sortWeight: 'N' }
      });
      expect(serverState).to.deep.equal(initialState);
      expect(clientState).to.not.deep.equal(initialState);
      expect(clientState).to.not.deep.equal(serverState);
    });

    it('4. Out of Server Band Edit: Snapshot 2', () => {
      // Initialize
      const serviceYDoc = new Y.Doc();
      const permanentUserDataConduit = new Y.PermanentUserData(serviceYDoc);
      permanentUserDataConduit.setUserMapping(serviceYDoc, serviceYDoc.clientID, serviceUserId);
      Y.applyUpdate(serviceYDoc, serverState);

      // Edit
      (serviceYDoc.getMap(mapKey).get('task_2')! as Y.Map<string>).set('sortWeight', 'B');
      
      // Save
      serverState = Y.encodeStateAsUpdate(serviceYDoc);
      
      // Snapshot
      const neverCollectedYDoc = new Y.Doc({ gc: false});
      Y.applyUpdate(neverCollectedYDoc, neverGarbageCollectedState);
      Y.applyUpdate(neverCollectedYDoc, serverState);
      snapshot2 = Y.snapshot(neverCollectedYDoc);
      neverGarbageCollectedState = Y.encodeStateAsUpdate(neverCollectedYDoc);

      // Assert
      expect(serviceYDoc.getMap(mapKey).toJSON()).to.deep.equal({
        task_1: { id: 'task_1', label: 'I am Task 1', sortWeight: 'M' },
        task_2: { id: 'task_2', label: 'I am Task 2', sortWeight: 'B' }
      });
      expect(serverState).to.not.deep.equal(initialState);
      expect(clientState).to.not.deep.equal(initialState);
      expect(clientState).to.not.deep.equal(serverState);
      expect(snapshot1).to.not.deep.equal(snapshot2);
    });

    it('5. Sync with Server: Snapshot 3', () => {
      // Sync
      const serviceYDoc = new Y.Doc();
      Y.applyUpdate(serviceYDoc, serverState);
      Y.applyUpdate(serviceYDoc, clientState);

      const clientYDoc = new Y.Doc();
      Y.applyUpdate(clientYDoc, clientState);
      Y.applyUpdate(clientYDoc, serverState);

      // Save 
      clientState = Y.encodeStateAsUpdate(clientYDoc);
      serverState = Y.encodeStateAsUpdate(serviceYDoc);

      // Save Snapshot
      const neverCollectedYDoc = new Y.Doc({ gc: false});
      Y.applyUpdate(neverCollectedYDoc, neverGarbageCollectedState);
      Y.applyUpdate(neverCollectedYDoc, serverState);
      snapshot3 = Y.snapshot(neverCollectedYDoc);
      neverGarbageCollectedState = Y.encodeStateAsUpdate(neverCollectedYDoc);

      // Assert
      expect(clientYDoc.getMap(mapKey).toJSON()).to.deep.equal({
        task_1: { id: 'task_1', label: 'Task 1 Label Updated', sortWeight: 'M' },
        task_2: { id: 'task_2', label: 'Task 2 Label Updated', sortWeight: 'B' }
      });
      expect(serverState).to.not.deep.equal(initialState);
      expect(clientState).to.not.deep.equal(initialState);
      expect(clientState).to.deep.equal(serverState);
      expect(snapshot1).to.not.deep.equal(snapshot2);
      expect(snapshot1).to.not.deep.equal(snapshot3);
      expect(snapshot2).to.not.deep.equal(snapshot3);
    });

    it('6. Revert to Snapshot 1', () => {
      // Initialize
      const clientYDoc = new Y.Doc();
      Y.applyUpdate(clientYDoc, clientState);

      // Snapshot and diff
      const neverCollectedYDoc = new Y.Doc({ gc: false });
      Y.applyUpdate(neverCollectedYDoc, neverGarbageCollectedState);
      const docAtSnapshot = Y.createDocFromSnapshot(neverCollectedYDoc, snapshot1);
      const diffForRevert = Y.diffUpdate(Y.encodeStateAsUpdate(docAtSnapshot), Y.encodeStateVector(clientYDoc));
      Y.applyUpdate(clientYDoc, diffForRevert);

      // Sync
      const serviceYDoc = new Y.Doc();
      Y.applyUpdate(serviceYDoc, serverState);
      Y.applyUpdate(serviceYDoc, Y.encodeStateAsUpdate(clientYDoc));
      Y.applyUpdate(serviceYDoc, serverState);

      // Save
      // Snapshot :-)
      clientState = Y.encodeStateAsUpdate(clientYDoc);
      serverState = Y.encodeStateAsUpdate(serviceYDoc);

      // Assert
      expect(docAtSnapshot.getMap(mapKey).toJSON()).to.deep.equal({
        task_1: { id: 'task_1', label: 'I am Task 1', sortWeight: 'M' },
        task_2: { id: 'task_2', label: 'I am Task 2', sortWeight: 'N' }
      });
      expect(clientYDoc.getMap(mapKey).toJSON()).to.deep.equal({
        task_1: { id: 'task_1', label: 'I am Task 1', sortWeight: 'M' },
        task_2: { id: 'task_2', label: 'I am Task 2', sortWeight: 'N' }
      });
      expect(serverState).to.not.deep.equal(initialState);
      expect(clientState).to.not.deep.equal(initialState);
      expect(clientState).to.deep.equal(serverState);
    });

    it('7. Revert to Snapshot 2', () => {
      // Initialize
      const clientYDoc = new Y.Doc();
      Y.applyUpdate(clientYDoc, clientState);

      // Snapshot and diff
      const neverCollectedYDoc = new Y.Doc({ gc: false });
      Y.applyUpdate(neverCollectedYDoc, neverGarbageCollectedState);
      const docAtSnapshot = Y.createDocFromSnapshot(neverCollectedYDoc, snapshot2);
      const diffForRevert = Y.diffUpdate(Y.encodeStateAsUpdate(docAtSnapshot), Y.encodeStateVector(clientYDoc));
      Y.applyUpdate(clientYDoc, diffForRevert);

      // Sync
      const serviceYDoc = new Y.Doc();
      Y.applyUpdate(serviceYDoc, serverState);
      Y.applyUpdate(serviceYDoc, Y.encodeStateAsUpdate(clientYDoc));
      Y.applyUpdate(serviceYDoc, serverState);

      // Save
      // Snapshot :-)
      clientState = Y.encodeStateAsUpdate(clientYDoc);
      serverState = Y.encodeStateAsUpdate(serviceYDoc);

      // Assert
      expect(docAtSnapshot.getMap(mapKey).toJSON()).to.deep.equal({
        task_1: { id: 'task_1', label: 'I am Task 1', sortWeight: 'M' },
        task_2: { id: 'task_2', label: 'I am Task 2', sortWeight: 'B' }
      });
      expect(serviceYDoc.getMap(mapKey).toJSON()).to.deep.equal({
        task_1: { id: 'task_1', label: 'I am Task 1', sortWeight: 'M' },
        task_2: { id: 'task_2', label: 'I am Task 2', sortWeight: 'B' }
      });
      expect(serverState).to.not.deep.equal(initialState);
      expect(clientState).to.not.deep.equal(initialState);
      expect(clientState).to.deep.equal(serverState);
    });

    it('8. Revert to Snapshot 3', () => {
      // Initialize
      const clientYDoc = new Y.Doc();
      Y.applyUpdate(clientYDoc, clientState);

      // Snapshot and diff
      const neverCollectedYDoc = new Y.Doc({ gc: false });
      Y.applyUpdate(neverCollectedYDoc, neverGarbageCollectedState);
      const docAtSnapshot = Y.createDocFromSnapshot(neverCollectedYDoc, snapshot3);
      const diffForRevert = Y.diffUpdate(Y.encodeStateAsUpdate(docAtSnapshot), Y.encodeStateVector(clientYDoc));
      Y.applyUpdate(clientYDoc, diffForRevert);

      // Sync
      const serviceYDoc = new Y.Doc();
      Y.applyUpdate(serviceYDoc, serverState);
      Y.applyUpdate(serviceYDoc, Y.encodeStateAsUpdate(clientYDoc));
      Y.applyUpdate(serviceYDoc, serverState);

      // Save
      // Snapshot :-)
      clientState = Y.encodeStateAsUpdate(clientYDoc);
      serverState = Y.encodeStateAsUpdate(serviceYDoc);

      // Assert
      expect(docAtSnapshot.getMap(mapKey).toJSON()).to.deep.equal({
        task_1: { id: 'task_1', label: 'Task 1 Label Updated', sortWeight: 'M' },
        task_2: { id: 'task_2', label: 'Task 2 Label Updated', sortWeight: 'B' }
      });
      expect(clientYDoc.getMap(mapKey).toJSON()).to.deep.equal({
        task_1: { id: 'task_1', label: 'Task 1 Label Updated', sortWeight: 'M' },
        task_2: { id: 'task_2', label: 'Task 2 Label Updated', sortWeight: 'B' }
      });
      expect(serverState).to.not.deep.equal(initialState);
      expect(clientState).to.not.deep.equal(initialState);
      expect(clientState).to.deep.equal(serverState);
    });
  });
});

Here are my results:

 Full Stack Interaction
    Snapshots
      ✔ 1. Initial State
      ✔ 2. Sync with Server: Snapshot 1
      ✔ 3. Send to Client with Offline Edit
      ✔ 4. Out of Server Band Edit: Snapshot 2
      ✔ 5. Sync with Server: Snapshot 3
      1) 6. Revert to Snapshot 1
      2) 7. Revert to Snapshot 2
      ✔ 8. Revert to Snapshot 3


  6 passing (57ms)
  2 failing

  1) Full Stack Interaction
       Snapshots
         6. Revert to Snapshot 1:

      AssertionError: expected { Object (task_1, task_2) } to deeply equal { Object (task_1, task_2) }
      + expected - actual

       {
         "task_1": {
           "id": "task_1"
      -    "label": "Task 1 Label Updated"
      +    "label": "I am Task 1"
           "sortWeight": "M"
         }
         "task_2": {
           "id": "task_2"
      -    "label": "Task 2 Label Updated"
      -    "sortWeight": "B"
      +    "label": "I am Task 2"
      +    "sortWeight": "N"
         }
       }
      
      at Context.<anonymous> (test/fullstack-yparts.spec.ts:196:58)
      at processImmediate (internal/timers.js:464:21)

  2) Full Stack Interaction
       Snapshots
         7. Revert to Snapshot 2:

      AssertionError: expected { Object (task_1, task_2) } to deeply equal { Object (task_1, task_2) }
      + expected - actual

       {
         "task_1": {
           "id": "task_1"
      -    "label": "Task 1 Label Updated"
      +    "label": "I am Task 1"
           "sortWeight": "M"
         }
         "task_2": {
           "id": "task_2"
      -    "label": "Task 2 Label Updated"
      +    "label": "I am Task 2"
           "sortWeight": "B"
         }
       }
      
      at Context.<anonymous> (test/fullstack-yparts.spec.ts:233:59)
      at processImmediate (internal/timers.js:464:21)

It looks like we cannot “revert forward” like a git revert? (using diff ops off of snapshots and the fully reconstructed doc from a snapshot?)

Refer to this issue for additional context: restoring document to a specific state · Issue #159 · yjs/yjs · GitHub

Hi @diomedtmc,

snapshots allow you to “see” old states. You also recreate an older state of the document, like you are doing. This enables you to read the old state. But that also means that you “forget” all the updates that were created ever since. FYI: some methods on the Yjs types allow you to supply a “snapshot” object to read an older state without recreating a Yjs document.

If you want to revert a document to a previous state, you should calculate the diff and apply the changes manually to the latest version of the Yjs document. I.e. If the new state is “ABC”, but the old state is “AC”, that means you should delete the character B manually.

Make sense, thank you for the fast response!