Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
395b4db
feat(json-crdt-peritext-ui): 🎸 implement text diff
streamich May 1, 2025
0fb9c7c
feat(util): 🎸 add diff utility methods
streamich May 2, 2025
9f35ef7
test(util): 💍 improve diff tests add fuzz tests
streamich May 2, 2025
016b166
test(util): 💍 add fuzz testing for diff
streamich May 2, 2025
163278a
test(util): 💍 add diffEdit() tests
streamich May 2, 2025
f1db4d8
feat(json-crdt-diff): 🎸 implement StrNode diff
streamich May 2, 2025
a0e148f
feat(json-crdt-diff): 🎸 remove deleted "obj" keys
streamich May 2, 2025
14e64fb
feat(json-crdt-diff): 🎸 insert missing keys into "obj"
streamich May 2, 2025
036b58e
feat(json-crdt-diff): 🎸 support entering "obj" entries
streamich May 2, 2025
8671b8c
chore(json-crdt-diff): 🤖 add shorter implementation
streamich May 2, 2025
587d2c5
feat(json-crdt-diff): 🎸 handle case where "obj" value not compatible …
streamich May 2, 2025
3abcfcf
feat(json-hash): 🎸 produce hashes for binary data
streamich May 2, 2025
3133a5f
test(json-hash): 💍 implement *structural hash* structHash() for any J…
streamich May 2, 2025
9187472
feat(json-hash): 🎸 assert structural hash in variants
streamich May 2, 2025
ada9067
feat(json-hash): 🎸 implement structural hashing for CRDT nodes
streamich May 3, 2025
b55ff6a
perf(json-hash): ⚡️ use custom insertion sort implementation
streamich May 3, 2025
030023b
feat(json-crdt-diff): 🎸 implement "val" node diff
streamich May 3, 2025
4e3067e
feat(json-crdt-diff): 🎸 implement diff for "vec" nodes
streamich May 3, 2025
d2489ad
style: 💄 small codebase cleanup
streamich May 3, 2025
67f5c77
refactor(util): 💡 move tests
streamich May 3, 2025
66c37c1
feat(util): 🎸 implement binary to hex transforms
streamich May 3, 2025
a740d02
feat(util): 🎸 implement diff for binary data
streamich May 3, 2025
1df2781
fix(json-crdt-diff): 🐛 use unicode encoding for binary data
streamich May 3, 2025
c03ede5
feat(json-crdt-diff): 🎸 implement "bin" node diffing
streamich May 4, 2025
a262307
refactor(util): 💡 cleanup string diffing code
streamich May 4, 2025
5968a47
feat(json-crdt-diff): 🎸 pass in source length on patch application
streamich May 4, 2025
fd88982
feat(json-crdt-diff): 🎸 implement initial "arr" node diff
streamich May 4, 2025
2a40471
feat(util): 🎸 initial, outer, implementation of array diff
streamich May 4, 2025
1853633
feat(util): 🎸 implement array diff child element entry
streamich May 4, 2025
bc25ff6
feat(json-crdt-diff): 🎸 implement array diff apply(), start using arr…
streamich May 4, 2025
7d68bc1
fix(json-crdt-diff): 🐛 improve array diff edge cases
streamich May 5, 2025
c6fbe17
fix(json-crdt-diff): 🐛 correct issues found by fuzzer
streamich May 5, 2025
0427a1a
feat(json-patch-diff): 🎸 setup JSON Patch diff algorithm implementation
streamich May 5, 2025
d7066d3
feat(json-patch-diff): 🎸 implement diff for object nodes
streamich May 5, 2025
0ebed6e
chore: 🤖 bump utils library version
streamich May 5, 2025
9ae5c90
feat(json-crdt-diff): 🎸 improve binary node handling
streamich May 5, 2025
a6a28ef
feat(json-patch-diff): 🎸 add array node diff implementation
streamich May 5, 2025
b75cbfe
feat(util): 🎸 implement line matching
streamich May 5, 2025
d89fb9e
feat(util): 🎸 add initial implementation of line matching
streamich May 6, 2025
64fc4b7
feat(util): 🎸 cleanup array diff
streamich May 6, 2025
cb247c8
fix(util): 🐛 correctly compute diff source offset
streamich May 6, 2025
1cbc8a5
fix(util): 🐛 handle correctly array shifts
streamich May 6, 2025
d786e53
feat(util): 🎸 initial by-line diff implementation
streamich May 7, 2025
5e500c1
test(json-patch-diff): 💍 add fuzz testing
streamich May 7, 2025
ca9ae65
feat(util): 🎸 create line-by-line patch module
streamich May 7, 2025
5ae14bb
refactor(util): 💡 rename string diff operation type enum values
streamich May 7, 2025
5ff05bf
feat(util): 🎸 handle new line chars
streamich May 7, 2025
bdde345
fix(util): 🐛 do not emit empty operations
streamich May 7, 2025
b6f142a
feat(util): 🎸 normalize line beginnings across inserts
streamich May 7, 2025
7383d95
fix(util): 🐛 do not emit empty operations
streamich May 7, 2025
b5a9b99
fix(util): 🐛 keep new-line on line end fixes
streamich May 8, 2025
68f131e
feat(util): 🎸 match line in line patch
streamich May 8, 2025
a29b4b7
fix(util): 🐛 correct line diff line beginning normalization
streamich May 8, 2025
5019ddf
test(util): 💍 add line diff fuzzer
streamich May 8, 2025
33a8316
fix(util): 🐛 improve handling of line separators
streamich May 8, 2025
82a97fe
fix(util): 🐛 correctly determini line operation on last line no-newli…
streamich May 8, 2025
20d1199
fix(util): 🐛 correctly assign last line patch operation
streamich May 9, 2025
33c6e57
test(util): 💍 pass line diff fuzz tests
streamich May 9, 2025
83694ad
feat(json-patch-diff): 🎸 use line diff in JSON Patch diff
streamich May 9, 2025
11eaaa8
fix(util): 🐛 improve line diff assignment, use a newline char per line
streamich May 9, 2025
ff97192
feat(json-crdt-diff): 🎸 use line diff in JSON CRDT diff
streamich May 10, 2025
cc166d9
test(json-crdt-diff): 💍 add fuzz testing for arrays
streamich May 10, 2025
941dbd5
test: 💍 fixup all tests
streamich May 10, 2025
0330f0d
perf(util): ⚡️ cleanup line diff algorithm
streamich May 10, 2025
68b8b27
fix(json-patch-diff): 🐛 allow string node type change
streamich May 10, 2025
12998ea
refactor(json-patch-diff): 💡 rename Diff to JsonPatchDiff
streamich May 10, 2025
414944c
refactor(json-crdt-diff): 💡 rename Diff to JsonCrdtDiff
streamich May 10, 2025
cda9d78
style: 💄 fix linter issues
streamich May 10, 2025
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
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
"@jsonjoy.com/json-pack": "^1.1.0",
"@jsonjoy.com/json-pointer": "^1.0.0",
"@jsonjoy.com/json-type": "^1.0.0",
"@jsonjoy.com/util": "^1.4.0",
"@jsonjoy.com/util": "^1.6.0",
"arg": "^5.0.2",
"hyperdyperid": "^1.2.0",
"nano-css": "^5.6.2",
Expand All @@ -108,6 +108,7 @@
"benchmark": "^2.1.4",
"config-galore": "^1.0.0",
"editing-traces": "https://github.com/streamich/editing-traces#6494020428530a6e382378b98d1d7e31334e2d7b",
"fast-diff": "^1.3.0",
"fast-json-patch": "^3.1.1",
"html-webpack-plugin": "^5.6.0",
"jest": "^29.7.0",
Expand Down Expand Up @@ -152,6 +153,7 @@
"",
"demo",
"json-cli",
"json-crdt-diff",
"json-crdt-patch",
"json-crdt-extensions",
"json-crdt-peritext-ui",
Expand All @@ -160,6 +162,7 @@
"json-ot",
"json-patch-ot",
"json-patch",
"json-patch-diff",
"json-stable",
"json-text",
"json-walk",
Expand Down
215 changes: 215 additions & 0 deletions src/json-crdt-diff/JsonCrdtDiff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import {deepEqual} from '@jsonjoy.com/util/lib/json-equal/deepEqual';
import {cmpUint8Array} from '@jsonjoy.com/util/lib/buffers/cmpUint8Array';
import {type ITimespanStruct, type ITimestampStruct, type Patch, PatchBuilder, Timespan} from '../json-crdt-patch';
import {ArrNode, BinNode, ConNode, ObjNode, StrNode, ValNode, VecNode, type JsonNode} from '../json-crdt/nodes';
import * as str from '../util/diff/str';
import * as bin from '../util/diff/bin';
import * as line from '../util/diff/line';
import {structHashCrdt} from '../json-hash/structHashCrdt';
import {structHash} from '../json-hash';
import type {Model} from '../json-crdt/model';

export class DiffError extends Error {
constructor(message: string = 'DIFF') {
super(message);
}
}

export class JsonCrdtDiff {
protected builder: PatchBuilder;

public constructor(protected readonly model: Model<any>) {
this.builder = new PatchBuilder(model.clock.clone());
}

protected diffStr(src: StrNode, dst: string): void {
const view = src.view();
if (view === dst) return;
const builder = this.builder;
str.apply(
str.diff(view, dst),
view.length,
(pos, txt) => builder.insStr(src.id, !pos ? src.id : src.find(pos - 1)!, txt),
(pos, len) => builder.del(src.id, src.findInterval(pos, len)),
);
}

protected diffBin(src: BinNode, dst: Uint8Array): void {
const view = src.view();
if (cmpUint8Array(view, dst)) return;
const builder = this.builder;
bin.apply(
bin.diff(view, dst),
view.length,
(pos, txt) => builder.insBin(src.id, !pos ? src.id : src.find(pos - 1)!, txt),
(pos, len) => builder.del(src.id, src.findInterval(pos, len)),
);
}

protected diffArr(src: ArrNode, dst: unknown[]): void {
const srcLines: string[] = [];
src.children((node) => {
srcLines.push(structHashCrdt(node));
});
const dstLines: string[] = [];
const dstLength = dst.length;
for (let i = 0; i < dstLength; i++) dstLines.push(structHash(dst[i]));
const linePatch = line.diff(srcLines, dstLines);
if (!linePatch.length) return;
const inserts: [after: ITimestampStruct, views: unknown[]][] = [];
const deletes: ITimespanStruct[] = [];
const patchLength = linePatch.length;
for (let i = patchLength - 1; i >= 0; i--) {
const [type, posSrc, posDst] = linePatch[i];
switch (type) {
case line.LINE_PATCH_OP_TYPE.EQL:
break;
case line.LINE_PATCH_OP_TYPE.INS: {
const view = dst[posDst];
const after = posSrc >= 0 ? src.find(posSrc) : src.id;
if (!after) throw new DiffError();
inserts.push([after, [view]]);
break;
}
case line.LINE_PATCH_OP_TYPE.DEL: {
const span = src.findInterval(posSrc, 1);
if (!span || !span.length) throw new DiffError();
deletes.push(...span);
break;
}
case line.LINE_PATCH_OP_TYPE.MIX: {
const view = dst[posDst];
try {
this.diffAny(src.getNode(posSrc)!, view);
} catch (error) {
if (error instanceof DiffError) {
const span = src.findInterval(posSrc, 1)!;
deletes.push(...span);
const after = posSrc ? src.find(posSrc - 1) : src.id;
if (!after) throw new DiffError();
inserts.push([after, [view]]);
} else throw error;
}
}
}
}
const builder = this.builder;
const length = inserts.length;
for (let i = 0; i < length; i++) {
const [after, views] = inserts[i];
builder.insArr(
src.id,
after,
views.map((view) => builder.json(view)),
);
}
if (deletes.length) builder.del(src.id, deletes);
}

protected diffObj(src: ObjNode, dst: Record<string, unknown>): void {
const builder = this.builder;
const inserts: [key: string, value: ITimestampStruct][] = [];
const srcKeys = new Set<string>();
// biome-ignore lint: .forEach is fastest here
src.forEach((key) => {
srcKeys.add(key);
const dstValue = dst[key];
if (dstValue === void 0) inserts.push([key, builder.const(undefined)]);
});
const keys = Object.keys(dst);
const length = keys.length;
for (let i = 0; i < length; i++) {
const key = keys[i];
const dstValue = dst[key];
if (srcKeys.has(key)) {
const child = src.get(key);
if (child) {
try {
this.diffAny(child, dstValue);
continue;
} catch (error) {
if (!(error instanceof DiffError)) throw error;
}
}
}
inserts.push([key, src.get(key) instanceof ConNode ? builder.const(dstValue) : builder.constOrJson(dstValue)]);
}
if (inserts.length) builder.insObj(src.id, inserts);
}

protected diffVec(src: VecNode, dst: unknown[]): void {
const builder = this.builder;
const edits: [key: number, value: ITimestampStruct][] = [];
const elements = src.elements;
const srcLength = elements.length;
const dstLength = dst.length;
const index = src.doc.index;
const min = Math.min(srcLength, dstLength);
for (let i = dstLength; i < srcLength; i++) {
const id = elements[i];
if (id) {
const child = index.get(id);
const isDeleted = !child || (child instanceof ConNode && child.val === void 0);
if (isDeleted) return;
edits.push([i, builder.const(void 0)]);
}
}
for (let i = 0; i < min; i++) {
const value = dst[i];
const child = src.get(i);
if (child) {
try {
this.diffAny(child, value);
continue;
} catch (error) {
if (!(error instanceof DiffError)) throw error;
}
}
edits.push([i, builder.constOrJson(value)]);
}
for (let i = srcLength; i < dstLength; i++) edits.push([i, builder.constOrJson(dst[i])]);
if (edits.length) builder.insVec(src.id, edits);
}

protected diffVal(src: ValNode, dst: unknown): void {
try {
this.diffAny(src.node(), dst);
} catch (error) {
if (error instanceof DiffError) {
const builder = this.builder;
builder.setVal(src.id, builder.constOrJson(dst));
} else throw error;
}
}

public diffAny(src: JsonNode, dst: unknown): void {
if (src instanceof ConNode) {
const val = src.val;
if (val !== dst && !deepEqual(src.val, dst)) throw new DiffError();
} else if (src instanceof StrNode) {
if (typeof dst !== 'string') throw new DiffError();
this.diffStr(src, dst);
} else if (src instanceof ObjNode) {
if (!dst || typeof dst !== 'object' || Array.isArray(dst)) throw new DiffError();
this.diffObj(src, dst as Record<string, unknown>);
} else if (src instanceof ValNode) {
this.diffVal(src, dst);
} else if (src instanceof ArrNode) {
if (!Array.isArray(dst)) throw new DiffError();
this.diffArr(src, dst as unknown[]);
} else if (src instanceof VecNode) {
if (!Array.isArray(dst)) throw new DiffError();
this.diffVec(src, dst as unknown[]);
} else if (src instanceof BinNode) {
if (!(dst instanceof Uint8Array)) throw new DiffError();
this.diffBin(src, dst);
} else {
throw new DiffError();
}
}

public diff(src: JsonNode, dst: unknown): Patch {
this.diffAny(src, dst);
return this.builder.flush();
}
}
53 changes: 53 additions & 0 deletions src/json-crdt-diff/__tests__/JsonCrdtDiff-fuzzing.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {JsonCrdtDiff} from '../JsonCrdtDiff';
import {Model} from '../../json-crdt/model';
import {RandomJson} from '@jsonjoy.com/util/lib/json-random';

const assertDiff = (src: unknown, dst: unknown) => {
const model = Model.create();
model.api.root(src);
const patch1 = new JsonCrdtDiff(model).diff(model.root, dst);
// console.log(model + '');
// console.log(patch1 + '');
model.applyPatch(patch1);
// console.log(model + '');
expect(model.view()).toEqual(dst);
const patch2 = new JsonCrdtDiff(model).diff(model.root, dst);
expect(patch2.ops.length).toBe(0);
};

const iterations = 1000;

test('from random JSON to random JSON', () => {
for (let i = 0; i < iterations; i++) {
const src = RandomJson.generate();
const dst = RandomJson.generate();
// console.log(src);
// console.log(dst);
assertDiff(src, dst);
}
});

test('two random arrays of integers', () => {
const iterations = 100;

const randomArray = () => {
const len = Math.floor(Math.random() * 10);
const arr: unknown[] = [];
for (let i = 0; i < len; i++) {
arr.push(Math.ceil(Math.random() * 13));
}
return arr;
};

for (let i = 0; i < iterations; i++) {
const src = randomArray();
const dst = randomArray();
try {
assertDiff(src, dst);
} catch (error) {
console.error('src', src);
console.error('dst', dst);
throw error;
}
}
});
Loading