Skip to content

Commit 35d1306

Browse files
authored
Merge pull request #892 from streamich/json-crdt-diff
Diffing tools
2 parents 3f9a231 + cda9d78 commit 35d1306

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+3389
-77
lines changed

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@
8989
"@jsonjoy.com/json-pack": "^1.1.0",
9090
"@jsonjoy.com/json-pointer": "^1.0.0",
9191
"@jsonjoy.com/json-type": "^1.0.0",
92-
"@jsonjoy.com/util": "^1.4.0",
92+
"@jsonjoy.com/util": "^1.6.0",
9393
"arg": "^5.0.2",
9494
"hyperdyperid": "^1.2.0",
9595
"nano-css": "^5.6.2",
@@ -108,6 +108,7 @@
108108
"benchmark": "^2.1.4",
109109
"config-galore": "^1.0.0",
110110
"editing-traces": "https://github.com/streamich/editing-traces#6494020428530a6e382378b98d1d7e31334e2d7b",
111+
"fast-diff": "^1.3.0",
111112
"fast-json-patch": "^3.1.1",
112113
"html-webpack-plugin": "^5.6.0",
113114
"jest": "^29.7.0",
@@ -152,6 +153,7 @@
152153
"",
153154
"demo",
154155
"json-cli",
156+
"json-crdt-diff",
155157
"json-crdt-patch",
156158
"json-crdt-extensions",
157159
"json-crdt-peritext-ui",
@@ -160,6 +162,7 @@
160162
"json-ot",
161163
"json-patch-ot",
162164
"json-patch",
165+
"json-patch-diff",
163166
"json-stable",
164167
"json-text",
165168
"json-walk",

src/json-crdt-diff/JsonCrdtDiff.ts

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import {deepEqual} from '@jsonjoy.com/util/lib/json-equal/deepEqual';
2+
import {cmpUint8Array} from '@jsonjoy.com/util/lib/buffers/cmpUint8Array';
3+
import {type ITimespanStruct, type ITimestampStruct, type Patch, PatchBuilder, Timespan} from '../json-crdt-patch';
4+
import {ArrNode, BinNode, ConNode, ObjNode, StrNode, ValNode, VecNode, type JsonNode} from '../json-crdt/nodes';
5+
import * as str from '../util/diff/str';
6+
import * as bin from '../util/diff/bin';
7+
import * as line from '../util/diff/line';
8+
import {structHashCrdt} from '../json-hash/structHashCrdt';
9+
import {structHash} from '../json-hash';
10+
import type {Model} from '../json-crdt/model';
11+
12+
export class DiffError extends Error {
13+
constructor(message: string = 'DIFF') {
14+
super(message);
15+
}
16+
}
17+
18+
export class JsonCrdtDiff {
19+
protected builder: PatchBuilder;
20+
21+
public constructor(protected readonly model: Model<any>) {
22+
this.builder = new PatchBuilder(model.clock.clone());
23+
}
24+
25+
protected diffStr(src: StrNode, dst: string): void {
26+
const view = src.view();
27+
if (view === dst) return;
28+
const builder = this.builder;
29+
str.apply(
30+
str.diff(view, dst),
31+
view.length,
32+
(pos, txt) => builder.insStr(src.id, !pos ? src.id : src.find(pos - 1)!, txt),
33+
(pos, len) => builder.del(src.id, src.findInterval(pos, len)),
34+
);
35+
}
36+
37+
protected diffBin(src: BinNode, dst: Uint8Array): void {
38+
const view = src.view();
39+
if (cmpUint8Array(view, dst)) return;
40+
const builder = this.builder;
41+
bin.apply(
42+
bin.diff(view, dst),
43+
view.length,
44+
(pos, txt) => builder.insBin(src.id, !pos ? src.id : src.find(pos - 1)!, txt),
45+
(pos, len) => builder.del(src.id, src.findInterval(pos, len)),
46+
);
47+
}
48+
49+
protected diffArr(src: ArrNode, dst: unknown[]): void {
50+
const srcLines: string[] = [];
51+
src.children((node) => {
52+
srcLines.push(structHashCrdt(node));
53+
});
54+
const dstLines: string[] = [];
55+
const dstLength = dst.length;
56+
for (let i = 0; i < dstLength; i++) dstLines.push(structHash(dst[i]));
57+
const linePatch = line.diff(srcLines, dstLines);
58+
if (!linePatch.length) return;
59+
const inserts: [after: ITimestampStruct, views: unknown[]][] = [];
60+
const deletes: ITimespanStruct[] = [];
61+
const patchLength = linePatch.length;
62+
for (let i = patchLength - 1; i >= 0; i--) {
63+
const [type, posSrc, posDst] = linePatch[i];
64+
switch (type) {
65+
case line.LINE_PATCH_OP_TYPE.EQL:
66+
break;
67+
case line.LINE_PATCH_OP_TYPE.INS: {
68+
const view = dst[posDst];
69+
const after = posSrc >= 0 ? src.find(posSrc) : src.id;
70+
if (!after) throw new DiffError();
71+
inserts.push([after, [view]]);
72+
break;
73+
}
74+
case line.LINE_PATCH_OP_TYPE.DEL: {
75+
const span = src.findInterval(posSrc, 1);
76+
if (!span || !span.length) throw new DiffError();
77+
deletes.push(...span);
78+
break;
79+
}
80+
case line.LINE_PATCH_OP_TYPE.MIX: {
81+
const view = dst[posDst];
82+
try {
83+
this.diffAny(src.getNode(posSrc)!, view);
84+
} catch (error) {
85+
if (error instanceof DiffError) {
86+
const span = src.findInterval(posSrc, 1)!;
87+
deletes.push(...span);
88+
const after = posSrc ? src.find(posSrc - 1) : src.id;
89+
if (!after) throw new DiffError();
90+
inserts.push([after, [view]]);
91+
} else throw error;
92+
}
93+
}
94+
}
95+
}
96+
const builder = this.builder;
97+
const length = inserts.length;
98+
for (let i = 0; i < length; i++) {
99+
const [after, views] = inserts[i];
100+
builder.insArr(
101+
src.id,
102+
after,
103+
views.map((view) => builder.json(view)),
104+
);
105+
}
106+
if (deletes.length) builder.del(src.id, deletes);
107+
}
108+
109+
protected diffObj(src: ObjNode, dst: Record<string, unknown>): void {
110+
const builder = this.builder;
111+
const inserts: [key: string, value: ITimestampStruct][] = [];
112+
const srcKeys = new Set<string>();
113+
// biome-ignore lint: .forEach is fastest here
114+
src.forEach((key) => {
115+
srcKeys.add(key);
116+
const dstValue = dst[key];
117+
if (dstValue === void 0) inserts.push([key, builder.const(undefined)]);
118+
});
119+
const keys = Object.keys(dst);
120+
const length = keys.length;
121+
for (let i = 0; i < length; i++) {
122+
const key = keys[i];
123+
const dstValue = dst[key];
124+
if (srcKeys.has(key)) {
125+
const child = src.get(key);
126+
if (child) {
127+
try {
128+
this.diffAny(child, dstValue);
129+
continue;
130+
} catch (error) {
131+
if (!(error instanceof DiffError)) throw error;
132+
}
133+
}
134+
}
135+
inserts.push([key, src.get(key) instanceof ConNode ? builder.const(dstValue) : builder.constOrJson(dstValue)]);
136+
}
137+
if (inserts.length) builder.insObj(src.id, inserts);
138+
}
139+
140+
protected diffVec(src: VecNode, dst: unknown[]): void {
141+
const builder = this.builder;
142+
const edits: [key: number, value: ITimestampStruct][] = [];
143+
const elements = src.elements;
144+
const srcLength = elements.length;
145+
const dstLength = dst.length;
146+
const index = src.doc.index;
147+
const min = Math.min(srcLength, dstLength);
148+
for (let i = dstLength; i < srcLength; i++) {
149+
const id = elements[i];
150+
if (id) {
151+
const child = index.get(id);
152+
const isDeleted = !child || (child instanceof ConNode && child.val === void 0);
153+
if (isDeleted) return;
154+
edits.push([i, builder.const(void 0)]);
155+
}
156+
}
157+
for (let i = 0; i < min; i++) {
158+
const value = dst[i];
159+
const child = src.get(i);
160+
if (child) {
161+
try {
162+
this.diffAny(child, value);
163+
continue;
164+
} catch (error) {
165+
if (!(error instanceof DiffError)) throw error;
166+
}
167+
}
168+
edits.push([i, builder.constOrJson(value)]);
169+
}
170+
for (let i = srcLength; i < dstLength; i++) edits.push([i, builder.constOrJson(dst[i])]);
171+
if (edits.length) builder.insVec(src.id, edits);
172+
}
173+
174+
protected diffVal(src: ValNode, dst: unknown): void {
175+
try {
176+
this.diffAny(src.node(), dst);
177+
} catch (error) {
178+
if (error instanceof DiffError) {
179+
const builder = this.builder;
180+
builder.setVal(src.id, builder.constOrJson(dst));
181+
} else throw error;
182+
}
183+
}
184+
185+
public diffAny(src: JsonNode, dst: unknown): void {
186+
if (src instanceof ConNode) {
187+
const val = src.val;
188+
if (val !== dst && !deepEqual(src.val, dst)) throw new DiffError();
189+
} else if (src instanceof StrNode) {
190+
if (typeof dst !== 'string') throw new DiffError();
191+
this.diffStr(src, dst);
192+
} else if (src instanceof ObjNode) {
193+
if (!dst || typeof dst !== 'object' || Array.isArray(dst)) throw new DiffError();
194+
this.diffObj(src, dst as Record<string, unknown>);
195+
} else if (src instanceof ValNode) {
196+
this.diffVal(src, dst);
197+
} else if (src instanceof ArrNode) {
198+
if (!Array.isArray(dst)) throw new DiffError();
199+
this.diffArr(src, dst as unknown[]);
200+
} else if (src instanceof VecNode) {
201+
if (!Array.isArray(dst)) throw new DiffError();
202+
this.diffVec(src, dst as unknown[]);
203+
} else if (src instanceof BinNode) {
204+
if (!(dst instanceof Uint8Array)) throw new DiffError();
205+
this.diffBin(src, dst);
206+
} else {
207+
throw new DiffError();
208+
}
209+
}
210+
211+
public diff(src: JsonNode, dst: unknown): Patch {
212+
this.diffAny(src, dst);
213+
return this.builder.flush();
214+
}
215+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {JsonCrdtDiff} from '../JsonCrdtDiff';
2+
import {Model} from '../../json-crdt/model';
3+
import {RandomJson} from '@jsonjoy.com/util/lib/json-random';
4+
5+
const assertDiff = (src: unknown, dst: unknown) => {
6+
const model = Model.create();
7+
model.api.root(src);
8+
const patch1 = new JsonCrdtDiff(model).diff(model.root, dst);
9+
// console.log(model + '');
10+
// console.log(patch1 + '');
11+
model.applyPatch(patch1);
12+
// console.log(model + '');
13+
expect(model.view()).toEqual(dst);
14+
const patch2 = new JsonCrdtDiff(model).diff(model.root, dst);
15+
expect(patch2.ops.length).toBe(0);
16+
};
17+
18+
const iterations = 1000;
19+
20+
test('from random JSON to random JSON', () => {
21+
for (let i = 0; i < iterations; i++) {
22+
const src = RandomJson.generate();
23+
const dst = RandomJson.generate();
24+
// console.log(src);
25+
// console.log(dst);
26+
assertDiff(src, dst);
27+
}
28+
});
29+
30+
test('two random arrays of integers', () => {
31+
const iterations = 100;
32+
33+
const randomArray = () => {
34+
const len = Math.floor(Math.random() * 10);
35+
const arr: unknown[] = [];
36+
for (let i = 0; i < len; i++) {
37+
arr.push(Math.ceil(Math.random() * 13));
38+
}
39+
return arr;
40+
};
41+
42+
for (let i = 0; i < iterations; i++) {
43+
const src = randomArray();
44+
const dst = randomArray();
45+
try {
46+
assertDiff(src, dst);
47+
} catch (error) {
48+
console.error('src', src);
49+
console.error('dst', dst);
50+
throw error;
51+
}
52+
}
53+
});

0 commit comments

Comments
 (0)