Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion lib/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
import * as sharedb from 'sharedb';
type Type = (typeof sharedb)['types']['map'][string];
export const type: Type;

// Contract a registered subtype implements so json0's diff can delegate to it
// for embedded values of that subtype (eg rich-text). isOfType lets diff recognise
// such a value by shape, since a raw value carries no type tag.
export interface DiffableSubtype {
diff(before: any, after: any): any;
isOfType(value: any): boolean;
}

export const type: Type & {
Comment thread
dodoarg marked this conversation as resolved.
Resolved
diff(before: any, after: any): sharedb.Op[];
};
144 changes: 144 additions & 0 deletions lib/json0.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
var deepEqual = require('fast-deep-equal');
var arrayDiff = require('arraydiff');

@dawidreedsy dawidreedsy Aug 12, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason we used that package that hasn't been updated for last 11 years, there is some other one updated 3 years ago https://www.npmjs.com/package/fast-array-diff

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea there is actually an explanation of this (and more additional approaches considered for diffing arrays) in the PR description. Basically the one you linked is lacking an equivalent of lm. Which is bad mostly because ld+li kills concurrent edits to the same moved element (and also because any plain reorder results in way larger ops)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For further context, that library is also the one that's used in sharedb, which is why I suggested it. If there's a bug in it, the bug will exist in sharedb and should also be fixed there.


/*
This is the implementation of the JSON OT type.
Expand Down Expand Up @@ -395,6 +396,149 @@ json.compose = function(op1,op2) {
return newOp;
};

// Produce an op that transforms `before` into `after`.
json.diff = function(before, after) {
Comment thread
dodoarg marked this conversation as resolved.
Resolved
return diffValue([], before, after);
};

function diffValue(path, before, after) {
if (deepEqual(before, after)) return [];

for (var name in subtypes) {
if (isTypeDiffable(subtypes[name], before, after)) {
var op = subtypes[name].diff(before, after);
return op && op.length ? [{p: path, t: name, o: op}] : []
}
}

if (isArray(before) && isArray(after)) {
return diffArray(path, before, after);
}

if (isObject(before) && isObject(after)) {
return diffObject(path, before, after);
}

return [{p: path, od: before, oi: after}];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON0 supports more operations.

{p:[path], na:x} - adds x to the number at [path].
{p:[path,offset], si:s} - inserts the string s at offset offset into the string at [path] (uses subtypes internally).
{p:[path,offset], sd:s} - deletes the string s at offset offset from the string at [path] (uses subtypes internally).

I wonder if we should utilise them, but maybe it is safer to just do what you did which is

[{p: path, od: before, oi: after}];

@dodoarg dodoarg Aug 12, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting point.
About si/sd:
these are already taken care of by the subtyping machinery. We delegate diffing strings to text0 and we get {t: 'text0', o: [...]} back with si/sd inside.
The only caveat is consumers need to register text0 as a subtype or isTypeDiffable would return false and the json0.diff would fall back to coarse od/oi. There's an argument where there is some kind of "specialized fallback" for diffing strings when consumers haven't registered text0 that would handle this case instead of the generic fallback, but I don't think it's worth implementing.

About na, this is a bit trickier.
It is true that no before/after pair emits na ops, and after investigating it turns out it is arguably correct. Essentially the core reason is if you're given a before and an after you can't tell the difference between "set a given number to X" and "increment a given number by X". The only way is to know the intent of the op author, but here we only have the starting and final state of the doc.
So if we emitted na ops as diff output we'd essentially be fabricating an incrementing intent. This could be dangerous in the context of rebasing stale ops (which is the entire reason we need to expose this diff machinery for) because two na ops transform additively via OT and that causes a double increment where there might not have been one.
Think:

  • doc is {n: 10}
  • Alice submits something like {p: ['n'], od: 10, oi: 12} (sets n to 12)
  • Bob concurrently submits {p: ['n'], na: 5} (increments n by 5)
  • a new hypothetical json0.diff branch emits {p: ['n'], na: 2} for Alice's op
  • that is transformed over Bob's op, the two nas are applied consecutively and finally doc is {n: 17}

While the od/oi fallback would result in last-write-wins over na i.e. {n: 12} which is arguably more reasonable as it's what currently happens for concurrent conflicting od/oi vs na

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That are very good points!

}

// A subtype can diff two values only if it can both diff and recognise them as its own documents.
function isTypeDiffable(subtype, before, after) {
return typeof subtype.diff === 'function' &&
typeof subtype.isOfType === 'function' &&
subtype.isOfType(before) &&
subtype.isOfType(after);
}

// Map arrayDiff's batched diffs to json0 list ops by replaying them against a
// working copy: json0's ld/li/lm mirror those splices, so the result round-trips.
// A block move expands to one lm per element, keeping each element's identity.
function diffArray(path, before, after) {
var work = before.slice();
var op = [];

detectSubstitutions(arrayDiff(before, after, deepEqual)).forEach(function(diff) {
switch (diff.type) {
case 'substitute': return diffSubstitution(op, work, path, diff);
case 'remove': return diffRemoval(op, work, path, diff);
case 'insert': return diffInsertion(op, work, path, diff);
case 'move': return diffMove(op, work, path, diff);
}
});

return op;
}

function diffSubstitution(op, work, path, diff) {
var current = work[diff.index];
// Recurse only when both elements are the same kind of container, so diffValue descends deeper into
// the path. Anything else (a primitive, or an object-to-array switch) has no shared shape to diff, so
// it stays a whole-element ld + li - a bare od/oi at a list index is invalid anyway.
if (sameContainerKind(current, diff.value)) {
op.push(...diffValue(path.concat(diff.index), current, diff.value));
} else {
op.push({p: path.concat(diff.index), ld: current});
op.push({p: path.concat(diff.index), li: diff.value});
}
work[diff.index] = diff.value;
}

function diffRemoval(op, work, path, diff) {
for (let i = 0; i < diff.howMany; i++) {
op.push({p: path.concat(diff.index), ld: work[diff.index]});
work.splice(diff.index, 1);
}
}

function diffInsertion(op, work, path, diff) {
for (let i = 0; i < diff.values.length; i++) {
op.push({p: path.concat(diff.index + i), li: diff.values[i]});
work.splice(diff.index + i, 0, diff.values[i]);
}
}

function diffMove(op, work, path, diff) {
// A move relocates a run of `howMany` elements from base index `from` to `to`. json0 has no block
// move, so we emit one `lm` per element - but the same reshape can be expressed two ways: slide the
// run to its destination (`howMany` lms), or slide the `from - to` displaced elements the other way
// past it (that many lms). Pick whichever needs fewer.
if (diff.howMany <= diff.from - diff.to) {
// Move the run: element-by-element from `from` to `to`.
for (let i = 0; i < diff.howMany; i++)
op.push({p: path.concat(diff.from + i), lm: diff.to + i});
} else {
// Move the displaced elements instead: each is lifted from `to` and dropped past the run.
for (let i = 0; i < diff.from - diff.to; i++)
op.push({p: path.concat(diff.to), lm: diff.from + diff.howMany - 1});
}
var block = work.splice(diff.from, diff.howMany);
work.splice(diff.to, 0, ...block);
}

function sameContainerKind(a, b) {
return (isArray(a) && isArray(b)) || (isObject(a) && isObject(b));
}

// arrayDiff only speaks remove/insert/move, so an element changed in place comes back as a
// remove immediately followed by an insert at the same index. Fuse that one unambiguous shape -
// remove of a single element, then insert of a single element at the same index - into a
// `substitute`, so diffArray can recurse into it. Anything else (batch replaces, differing counts)
// is left untouched and keeps its literal ld/li. A move is never a fusion candidate: arrayDiff
// matches equal elements, so a remove+insert pair always has differing values.
function detectSubstitutions(diffs) {
var diffsWithSubs = [];
for (var i = 0; i < diffs.length; i++) {
var current = diffs[i];
var next = diffs[i + 1];
if (
current.type === 'remove' && current.howMany === 1 &&
next && next.type === 'insert' && next.values.length === 1 &&
next.index === current.index
) {
diffsWithSubs.push({type: 'substitute', index: current.index, value: next.values[0]});
i++;
} else {
diffsWithSubs.push(current);
}
}
return diffsWithSubs;
}

function diffObject(path, before, after) {
var op = [];
Object.keys(before).forEach(function(key) {
if (!hasOwn(after, key)) op.push({p: path.concat(key), od: before[key]});
Comment thread
dodoarg marked this conversation as resolved.
Resolved
});
Comment thread
dodoarg marked this conversation as resolved.
Resolved
Object.keys(after).forEach(function(key) {
if (!hasOwn(before, key)) {
op.push({p: path.concat(key), oi: after[key]});
} else {
op.push(...diffValue(path.concat(key), before[key], after[key]));
}
});
return op;
}

json.normalize = function(op) {
var newOp = [];

Expand Down
34 changes: 34 additions & 0 deletions lib/text0.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
// is equivalent to this op:
// [{i:'a', p:0}, {i:'b', p:1}, {i:'c', p:2}]

var fastDiff = require('fast-diff');

var text = module.exports = {
name: 'text0',
uri: 'http://sharejs.org/types/textv0',
Expand Down Expand Up @@ -117,6 +119,38 @@ text.compose = function(op1, op2) {
return newOp;
};


// Compute the op that transforms `before` into `after`, delegating to fast-diff (the same algorithm
// quill-delta uses for rich-text) and mapping its equal/insert/delete segments to text0 ops.
// fast-diff is O(n*d) in the edit distance: fine when the two strings share most content (a small edit in
// a huge doc strips to a tiny residual), but degrades to ~O(n^2) for large, substantially-different pairs.
// See <editor issue>
text.diff = function(before, after) {
var op = [];
var p = 0;
fastDiff(before, after).forEach(function(segment) {
var kind = segment[0];
var value = segment[1];
switch (kind) {
case fastDiff.EQUAL:
p += value.length;
break;
case fastDiff.DELETE:
op.push({p: p, d: value});
break;
case fastDiff.INSERT:
op.push({p: p, i: value});
p += value.length;
break;
}
});
return op;
Comment on lines +131 to +147

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we have the same diff for strings in json0?

@dodoarg dodoarg Aug 12, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is the text0 diff; json0 already reuses it via the subtype path, so json0 does get identical string diffing whenever text0 is registered. Back to my point to the other thread we could implement a string "fallback" for when no text0 has been registered on the consumer end so doing it would be "correct" but arguably redundant? Since this is our fork and we know we will register text0 on the sharedb consumer for the rebase machinery (which is probably gonna stay its one consumer)

};

text.isOfType = function(snapshot) {
return typeof snapshot === 'string';
};

/** Clean up an op */
text.normalize = function(op) {
var newOp = [];
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
},
"dependencies": {
"@types/sharedb": "^5.1.0",
"fast-deep-equal": "^2.0.1"
"arraydiff": "^0.1.3",
"fast-deep-equal": "^2.0.1",
"fast-diff": "^1.3.0"
},
"devDependencies": {
"coffee-script": "^1.7.1",
Expand Down
Loading
Loading