Skip to content

Commit 427c7c9

Browse files
committed
feat(json-crdt-patch): 🎸 implement combine() for joining two adjacent patches
1 parent b361694 commit 427c7c9

File tree

3 files changed

+131
-1
lines changed

3 files changed

+131
-1
lines changed

‎src/json-crdt-patch/Patch.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export class Patch implements Printable {
6262
/**
6363
* A list of operations in the patch.
6464
*/
65-
public readonly ops: JsonCrdtPatchOperation[] = [];
65+
public ops: JsonCrdtPatchOperation[] = [];
6666

6767
/**
6868
* Arbitrary metadata associated with the patch, which is not used by the
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import {LogicalClock} from '../clock';
2+
import {combine} from '../compaction';
3+
import {SESSION} from '../constants';
4+
import {NopOp} from '../operations';
5+
import {Patch} from '../Patch';
6+
import {PatchBuilder} from '../PatchBuilder';
7+
8+
describe('.combine()', () => {
9+
test('can combine two adjacent patches', () => {
10+
const builder = new PatchBuilder(new LogicalClock(123456789, 1));
11+
const objId = builder.json({
12+
str: 'hello',
13+
num: 123,
14+
tags: ['a', 'b', 'c'],
15+
});
16+
builder.root(objId);
17+
const patch = builder.flush();
18+
const str1 = patch + '';
19+
for (let i = 1; i < patch.ops.length - 2; i++) {
20+
const ops1 = patch.ops.slice(0, i);
21+
const ops2 = patch.ops.slice(i);
22+
const patch1 = new Patch();
23+
patch1.ops = patch1.ops.concat(ops1);
24+
const patch2 = new Patch();
25+
patch2.ops = patch2.ops.concat(ops2);
26+
combine(patch1, patch2);
27+
const str2 = patch1 + '';
28+
expect(str2).toBe(str1);
29+
}
30+
});
31+
32+
test('can combine two patches with gap', () => {
33+
const builder1 = new PatchBuilder(new LogicalClock(123456789, 1));
34+
const builder2 = new PatchBuilder(new LogicalClock(123456789, 100));
35+
builder1.str();
36+
builder2.const(123);
37+
const patch1 = builder1.flush();
38+
const patch2 = builder2.flush();
39+
combine(patch1, patch2);
40+
expect(patch1.ops.length).toBe(3);
41+
expect(patch1.ops[1]).toBeInstanceOf(NopOp);
42+
const nop = patch1.ops[1] as NopOp;
43+
expect(nop.id.sid).toBe(123456789);
44+
expect(nop.id.time).toBe(2);
45+
expect(nop.len).toBe(98);
46+
});
47+
48+
test('throws on mismatching sessions', () => {
49+
const builder1 = new PatchBuilder(new LogicalClock(1111111, 1));
50+
const builder2 = new PatchBuilder(new LogicalClock(2222222, 100));
51+
builder1.str();
52+
builder2.const(123);
53+
const patch1 = builder1.flush();
54+
const patch2 = builder2.flush();
55+
expect(() => combine(patch1, patch2)).toThrow(new Error('SID_MISMATCH'));
56+
});
57+
58+
test('first patch can be empty', () => {
59+
const builder2 = new PatchBuilder(new LogicalClock(2222222, 100));
60+
builder2.const(123);
61+
const patch1 = new Patch();
62+
const patch2 = builder2.flush();
63+
combine(patch1, patch2);
64+
expect(patch1 + '').toBe(patch2 + '');
65+
});
66+
67+
test('second patch can be empty', () => {
68+
const builder2 = new PatchBuilder(new LogicalClock(2222222, 100));
69+
builder2.const(123);
70+
const patch1 = new Patch();
71+
const patch2 = builder2.flush();
72+
const str1 = patch2 + '';
73+
combine(patch2, patch1);
74+
const str2 = patch2 + '';
75+
expect(str2).toBe(str1);
76+
expect(patch1.getId()).toBe(undefined);
77+
});
78+
79+
test('throws if first patch has higher logical time', () => {
80+
const builder1 = new PatchBuilder(new LogicalClock(123456789, 1));
81+
const builder2 = new PatchBuilder(new LogicalClock(123456789, 100));
82+
builder1.str();
83+
builder2.const(123);
84+
const patch1 = builder1.flush();
85+
const patch2 = builder2.flush();
86+
expect(() => combine(patch2, patch1)).toThrow(new Error('TIMESTAMP_CONFLICT'));
87+
combine(patch1, patch2)
88+
});
89+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* @description Operations for combining patches together, combining operations
3+
* together, and cleaning up operations.
4+
*/
5+
6+
import {Timestamp} from "./clock";
7+
import {NopOp} from "./operations";
8+
import type {Patch} from "./Patch";
9+
10+
/**
11+
* Combines two patches together. The first patch is modified in place.
12+
* Operations from the second patch are appended to the first patch as is
13+
* (without cloning).
14+
*
15+
* The patches must have the same `sid`. The first patch must have lower logical
16+
* time than the second patch, and the logical times must not overlap.
17+
*
18+
* @param a First patch to combine.
19+
* @param b Second patch to combine.
20+
*/
21+
export const combine = (a: Patch, b: Patch): void => {
22+
const idA = a.getId();
23+
const idB = b.getId();
24+
if (!idA) {
25+
if (!idB) return;
26+
a.ops = a.ops.concat(b.ops);
27+
return;
28+
}
29+
if (!idB) return;
30+
if (!idA || !idB) throw new Error('EMPTY_PATCH');
31+
const sidA = idA.sid;
32+
if (sidA !== idB.sid) throw new Error('SID_MISMATCH');
33+
const timeA = idA.time;
34+
const nextTick = timeA + a.span();
35+
const timeB = idB.time;
36+
const timeDiff = timeB - nextTick;
37+
if (timeDiff < 0) throw new Error('TIMESTAMP_CONFLICT');
38+
const needsNoop = timeDiff > 0;
39+
if (needsNoop) a.ops.push(new NopOp(new Timestamp(sidA, nextTick), timeDiff));
40+
a.ops = a.ops.concat(b.ops);
41+
};

0 commit comments

Comments
 (0)