Skip to content

Commit bb52208

Browse files
committed
feat(json-crdt-patch): 🎸 allow combining multiple patches at once
1 parent 67feb63 commit bb52208

File tree

2 files changed

+76
-28
lines changed

2 files changed

+76
-28
lines changed

src/json-crdt-patch/__tests__/compaction.spec.ts

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,27 +22,71 @@ describe('.combine()', () => {
2222
patch1.ops = patch1.ops.concat(ops1);
2323
const patch2 = new Patch();
2424
patch2.ops = patch2.ops.concat(ops2);
25-
combine(patch1, patch2);
25+
combine([patch1, patch2]);
2626
const str2 = patch1 + '';
2727
expect(str2).toBe(str1);
2828
}
2929
});
3030

31+
test('can combine three adjacent patches', () => {
32+
const builder = new PatchBuilder(new LogicalClock(123456789, 1));
33+
const objId = builder.json({
34+
str: 'hello',
35+
num: 123,
36+
tags: ['a', 'b', 'c'],
37+
});
38+
builder.root(objId);
39+
const patch = builder.flush();
40+
const str1 = patch + '';
41+
const ops1 = patch.ops.slice(0, 2);
42+
const ops2 = patch.ops.slice(2, 4);
43+
const ops3 = patch.ops.slice(4);
44+
const patch1 = new Patch();
45+
patch1.ops = patch1.ops.concat(ops1);
46+
const patch2 = new Patch();
47+
patch2.ops = patch2.ops.concat(ops2);
48+
const patch3 = new Patch();
49+
patch3.ops = patch3.ops.concat(ops3);
50+
combine([patch1, patch2, patch3]);
51+
const str2 = patch1 + '';
52+
expect(str2).toBe(str1);
53+
});
54+
3155
test('can combine two patches with gap', () => {
3256
const builder1 = new PatchBuilder(new LogicalClock(123456789, 1));
3357
const builder2 = new PatchBuilder(new LogicalClock(123456789, 100));
3458
builder1.str();
3559
builder2.const(123);
3660
const patch1 = builder1.flush();
3761
const patch2 = builder2.flush();
38-
combine(patch1, patch2);
62+
combine([patch1, patch2]);
3963
expect(patch1.ops.length).toBe(3);
4064
expect(patch1.ops[1]).toBeInstanceOf(NopOp);
4165
const nop = patch1.ops[1] as NopOp;
4266
expect(nop.id.sid).toBe(123456789);
4367
expect(nop.id.time).toBe(2);
4468
expect(nop.len).toBe(98);
4569
});
70+
71+
test('can combine four patches with gap', () => {
72+
const builder1 = new PatchBuilder(new LogicalClock(123456789, 1));
73+
const builder2 = new PatchBuilder(new LogicalClock(123456789, 100));
74+
const builder3 = new PatchBuilder(new LogicalClock(123456789, 110));
75+
const builder4 = new PatchBuilder(new LogicalClock(123456789, 220));
76+
builder1.str();
77+
builder2.const(123);
78+
builder3.obj();
79+
builder4.bin();
80+
const patch1 = builder1.flush();
81+
const patch2 = builder2.flush();
82+
const patch3 = builder3.flush();
83+
const patch4 = builder4.flush();
84+
combine([patch1, patch2, patch3, patch4]);
85+
expect(patch1.ops.length).toBe(7);
86+
expect(patch1.ops[1]).toBeInstanceOf(NopOp);
87+
expect(patch1.ops[3]).toBeInstanceOf(NopOp);
88+
expect(patch1.ops[5]).toBeInstanceOf(NopOp);
89+
});
4690

4791
test('throws on mismatching sessions', () => {
4892
const builder1 = new PatchBuilder(new LogicalClock(1111111, 1));
@@ -51,15 +95,15 @@ describe('.combine()', () => {
5195
builder2.const(123);
5296
const patch1 = builder1.flush();
5397
const patch2 = builder2.flush();
54-
expect(() => combine(patch1, patch2)).toThrow(new Error('SID_MISMATCH'));
98+
expect(() => combine([patch1, patch2])).toThrow(new Error('SID_MISMATCH'));
5599
});
56100

57101
test('first patch can be empty', () => {
58102
const builder2 = new PatchBuilder(new LogicalClock(2222222, 100));
59103
builder2.const(123);
60104
const patch1 = new Patch();
61105
const patch2 = builder2.flush();
62-
combine(patch1, patch2);
106+
combine([patch1, patch2]);
63107
expect(patch1 + '').toBe(patch2 + '');
64108
});
65109

@@ -69,7 +113,7 @@ describe('.combine()', () => {
69113
const patch1 = new Patch();
70114
const patch2 = builder2.flush();
71115
const str1 = patch2 + '';
72-
combine(patch2, patch1);
116+
combine([patch2, patch1]);
73117
const str2 = patch2 + '';
74118
expect(str2).toBe(str1);
75119
expect(patch1.getId()).toBe(undefined);
@@ -82,8 +126,8 @@ describe('.combine()', () => {
82126
builder2.const(123);
83127
const patch1 = builder1.flush();
84128
const patch2 = builder2.flush();
85-
expect(() => combine(patch2, patch1)).toThrow(new Error('TIMESTAMP_CONFLICT'));
86-
combine(patch1, patch2)
129+
expect(() => combine([patch2, patch1])).toThrow(new Error('TIMESTAMP_CONFLICT'));
130+
combine([patch1, patch2])
87131
});
88132
});
89133

src/json-crdt-patch/compaction.ts

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,36 +8,40 @@ import {InsStrOp, NopOp} from "./operations";
88
import type {JsonCrdtPatchOperation, Patch} from "./Patch";
99

1010
/**
11-
* Combines two patches together. The first patch is modified in place.
11+
* Combines two or more patches together. The first patch is modified in place.
1212
* Operations from the second patch are appended to the first patch as is
1313
* (without cloning).
1414
*
1515
* The patches must have the same `sid`. The first patch must have lower logical
1616
* time than the second patch, and the logical times must not overlap.
1717
*
18-
* @param a First patch to combine.
19-
* @param b Second patch to combine.
18+
* @param patches The patches to combine.
2019
*/
21-
export const combine = (a: Patch, b: Patch): void => {
22-
const idA = a.getId();
23-
const idB = b.getId();
24-
if (!idA) {
20+
export const combine = (patches: Patch[]): void => {
21+
const firstPatch = patches[0];
22+
let idA = patches[0].getId();
23+
const patchesLength = patches.length;
24+
for (let i = 1; i < patchesLength; i++) {
25+
const currentPatch = patches[i];
26+
const idB = currentPatch.getId();
27+
if (!idA) {
28+
if (!idB) return;
29+
firstPatch.ops = firstPatch.ops.concat(currentPatch.ops);
30+
return;
31+
}
2532
if (!idB) return;
26-
a.ops = a.ops.concat(b.ops);
27-
return;
33+
if (!idA || !idB) throw new Error('EMPTY_PATCH');
34+
const sidA = idA.sid;
35+
if (sidA !== idB.sid) throw new Error('SID_MISMATCH');
36+
const timeA = idA.time;
37+
const nextTick = timeA + firstPatch.span();
38+
const timeB = idB.time;
39+
const timeDiff = timeB - nextTick;
40+
if (timeDiff < 0) throw new Error('TIMESTAMP_CONFLICT');
41+
const needsNoop = timeDiff > 0;
42+
if (needsNoop) firstPatch.ops.push(new NopOp(new Timestamp(sidA, nextTick), timeDiff));
43+
firstPatch.ops = firstPatch.ops.concat(currentPatch.ops);
2844
}
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);
4145
};
4246

4347
/**

0 commit comments

Comments
 (0)