automerge / automerge-classic

A JSON-like data structure (a CRDT) that can be modified concurrently by different users, and merged again automatically.
http://automerge.org/
MIT License
14.75k stars 466 forks source link

OpIds are missing from Operations #423

Closed begor closed 3 years ago

begor commented 3 years ago

Hey folks!

I've noticed that if you use automerge.getHistory(doc) API, you won't get IDs of objects that were created.

Consider a toy example:

class MyRootState {
  first: string = "";
  nested?: MyNestedState;
}

class MyNestedState {
  second: string = "";
}

const root = new MyRootState();
const doc = automerge.from(root);

// Some changes to retrieve initial history.
const newDoc = automerge.change(doc, (doc: automerge.Proxy<MyRootState>) => {
  doc.first = "A";

  const nested = new MyNestedState();
  doc.nested = nested;
  doc.nested.second = "B";
});

automerge.getHistory(newDoc).forEach((state) => {
  const change = state.change;
  for (const op of change.ops) {
    console.log(`Operation=${JSON.stringify(op)}`)
  }
});

We'll get:

Operation={"obj":"_root","key":"first","action":"set","insert":false,"value":"","pred":[]}
Operation={"obj":"_root","key":"first","action":"set","insert":false,"value":"A","pred":["1@f763a647010f47358a22321e1445a2a5"]} 
Operation={"obj":"_root","key":"nested","action":"makeMap","insert":false,"pred":[]}
Operation={"obj":"3@f763a647010f47358a22321e1445a2a5","key":"second","action":"set","insert":false,"value":"","pred":[]}
Operation={"obj":"3@f763a647010f47358a22321e1445a2a5","key":"second","action":"set","insert":false,"value":"B","pred":["4@f763a647010f47358a22321e1445a2a5"]}

It turns out for operations like makeMap:

{"obj":"_root","key":"first","action":"set","insert":false,"value":"A","pred":["1@f763a647010f47358a22321e1445a2a5"]} 

we don't include objId for that newly created map. We only include it for operations that actually mutate the map:

{"obj":"**3@f763a647010f47358a22321e1445a2a5**","key":"second","action":"set","insert":false,"value":"","pred":[]}
{"obj":"**3@f763a647010f47358a22321e1445a2a5**","key":"second","action":"set","insert":false,"value":"B","pred":["4@f763a647010f47358a22321e1445a2a5"]}

After some thought it looks like we should be able to reconstruct id from change.actor and change.seq with the following logic:

automerge.getHistory(newDoc).forEach((state) => {
  const change = state.change;
  const actor = change.actor;
  let seq = change.seq;

  for (const op of change.ops) {
    const shouldBeOpID = `${seq}@${actor}`;
    seq++;

    console.log(`Operation=${JSON.stringify(op)}, opId=${shouldBeOpID}`)
  }
});

Two questions: 1) Is this a correct approach? 2) Why Operation doesn't include OpId in the first place? Is purely to optimize the size of Operation struct?

Thanks in advance!

ept commented 3 years ago

Hi @begor, when a new object is created using a make* operation, the ID of the new object is the OpId of the operation that created it. That OpId is not included in the operation since it's redundant (in the sense that it can be computed from other fields), although we could consider adding it if that makes life easier.

Your code for computing the OpIds is not quite correct: the first OpId in a change is

`${change.startOp}@${change.actor}`

and then number before the @ is incremented for subsequent ops. That is, you need to start with change.startOp, not with change.seq.

begor commented 3 years ago

Thanks a lot, Martin!

With #426 now merged, it's now easy to do ${change.startOp}@${change.actor}, so there's probably no need in adding it to Op.