Skip to content

Commit 67feb63

Browse files
committed
feat(json-crdt-patch): 🎸 implement consecutive string insert compaction
1 parent 427c7c9 commit 67feb63

File tree

3 files changed

+121
-8
lines changed

3 files changed

+121
-8
lines changed

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

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import {LogicalClock} from '../clock';
2-
import {combine} from '../compaction';
3-
import {SESSION} from '../constants';
4-
import {NopOp} from '../operations';
1+
import {equal, LogicalClock, tick, ts} from '../clock';
2+
import {combine, compact} from '../compaction';
3+
import {InsStrOp, NopOp} from '../operations';
54
import {Patch} from '../Patch';
65
import {PatchBuilder} from '../PatchBuilder';
76

@@ -87,3 +86,86 @@ describe('.combine()', () => {
8786
combine(patch1, patch2)
8887
});
8988
});
89+
90+
describe('.compact()', () => {
91+
test('can combine two consecutive string inserts', () => {
92+
const builder = new PatchBuilder(new LogicalClock(123456789, 1));
93+
const strId = builder.str();
94+
const ins1Id = builder.insStr(strId, strId, 'hello');
95+
builder.insStr(strId, tick(ins1Id, 'hello'.length - 1), ' world');
96+
builder.root(strId);
97+
const patch = builder.flush();
98+
const patch2 = patch.clone();
99+
compact(patch);
100+
expect(equal(patch.ops[0].id, patch2.ops[0].id)).toBe(true);
101+
expect(equal(patch.ops[1].id, patch2.ops[1].id)).toBe(true);
102+
expect(equal(patch.ops[2].id, patch2.ops[3].id)).toBe(true);
103+
expect((patch.ops[1] as any).data).toBe('hello world');
104+
});
105+
106+
test('can combine two consecutive string inserts - 2', () => {
107+
const builder = new PatchBuilder(new LogicalClock(123456789, 1));
108+
const strId = builder.str();
109+
const ins1Id = builder.insStr(strId, strId, 'a');
110+
builder.insStr(strId, tick(ins1Id, 'a'.length - 1), 'b');
111+
builder.root(strId);
112+
const patch = builder.flush();
113+
const patch2 = patch.clone();
114+
compact(patch);
115+
expect(equal(patch.ops[0].id, patch2.ops[0].id)).toBe(true);
116+
expect(equal(patch.ops[1].id, patch2.ops[1].id)).toBe(true);
117+
expect(equal(patch.ops[2].id, patch2.ops[3].id)).toBe(true);
118+
expect((patch.ops[1] as any).data).toBe('ab');
119+
});
120+
121+
test('can combine two consecutive string inserts - 3', () => {
122+
const patch = new Patch();
123+
patch.ops.push(new InsStrOp(ts(123, 30), ts(123, 10), ts(123, 20), 'a'));
124+
patch.ops.push(new InsStrOp(ts(123, 31), ts(123, 10), ts(123, 30), 'b'));
125+
compact(patch);
126+
expect(patch.ops.length).toBe(1);
127+
expect((patch.ops[0] as InsStrOp).data).toBe('ab');
128+
});
129+
130+
test('does not combine inserts, if they happen into different strings', () => {
131+
const patch = new Patch();
132+
patch.ops.push(new InsStrOp(ts(123, 30), ts(123, 10), ts(123, 20), 'a'));
133+
patch.ops.push(new InsStrOp(ts(123, 31), ts(123, 99), ts(123, 30), 'b'));
134+
compact(patch);
135+
expect(patch.ops.length).toBe(2);
136+
});
137+
138+
test('does not combine inserts, if time is not consecutive', () => {
139+
const patch = new Patch();
140+
patch.ops.push(new InsStrOp(ts(123, 30), ts(123, 10), ts(123, 20), 'a'));
141+
patch.ops.push(new InsStrOp(ts(123, 99), ts(123, 10), ts(123, 30), 'b'));
142+
compact(patch);
143+
expect(patch.ops.length).toBe(2);
144+
});
145+
146+
test('does not combine inserts, if the second operation is not an append', () => {
147+
const patch = new Patch();
148+
patch.ops.push(new InsStrOp(ts(123, 30), ts(123, 10), ts(123, 20), 'a'));
149+
patch.ops.push(new InsStrOp(ts(123, 31), ts(123, 10), ts(123, 22), 'b'));
150+
compact(patch);
151+
expect(patch.ops.length).toBe(2);
152+
});
153+
154+
test('does not combine inserts, if the second operation is not an append - 2', () => {
155+
const patch = new Patch();
156+
patch.ops.push(new InsStrOp(ts(123, 30), ts(123, 10), ts(123, 20), 'a'));
157+
patch.ops.push(new InsStrOp(ts(123, 31), ts(123, 10), ts(999, 30), 'b'));
158+
compact(patch);
159+
expect(patch.ops.length).toBe(2);
160+
});
161+
162+
test('returns a patch as-is', () => {
163+
const builder = new PatchBuilder(new LogicalClock(123456789, 1));
164+
builder.root(builder.json({str: 'hello'}));
165+
const patch = builder.flush();
166+
const str1 = patch + '';
167+
compact(patch);
168+
const str2 = patch + '';
169+
expect(str2).toBe(str1);
170+
});
171+
});

src/json-crdt-patch/compaction.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
* together, and cleaning up operations.
44
*/
55

6-
import {Timestamp} from "./clock";
7-
import {NopOp} from "./operations";
8-
import type {Patch} from "./Patch";
6+
import {equal, Timestamp} from "./clock";
7+
import {InsStrOp, NopOp} from "./operations";
8+
import type {JsonCrdtPatchOperation, Patch} from "./Patch";
99

1010
/**
1111
* Combines two patches together. The first patch is modified in place.
@@ -39,3 +39,34 @@ export const combine = (a: Patch, b: Patch): void => {
3939
if (needsNoop) a.ops.push(new NopOp(new Timestamp(sidA, nextTick), timeDiff));
4040
a.ops = a.ops.concat(b.ops);
4141
};
42+
43+
/**
44+
* Compacts operations within a patch. Combines consecutive string inserts.
45+
* Mutates the operations in place. (Use `patch.clone()` to avoid mutating the
46+
* original patch.)
47+
*
48+
* @param patch The patch to compact.
49+
*/
50+
export const compact = (patch: Patch): void => {
51+
const ops = patch.ops;
52+
const length = ops.length;
53+
let lastOp: JsonCrdtPatchOperation = ops[0];
54+
const newOps: JsonCrdtPatchOperation[] = [lastOp];
55+
for (let i = 1; i < length; i++) {
56+
const op = ops[i];
57+
if (lastOp instanceof InsStrOp && op instanceof InsStrOp) {
58+
const lastOpNextTick = lastOp.id.time + lastOp.span();
59+
const isTimeConsecutive = lastOpNextTick === op.id.time;
60+
const isInsertIntoSameString = equal(lastOp.obj, op.obj);
61+
const opRef = op.ref;
62+
const isAppend = (lastOpNextTick === (opRef.time + 1)) && (lastOp.ref.sid === opRef.sid);
63+
if (isTimeConsecutive && isInsertIntoSameString && isAppend) {
64+
lastOp.data = lastOp.data + op.data;
65+
continue;
66+
}
67+
}
68+
newOps.push(op);
69+
lastOp = op;
70+
}
71+
patch.ops = newOps;
72+
};

src/json-crdt-patch/operations.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ export class InsStrOp implements IJsonCrdtPatchEditOperation {
262262
public readonly id: ITimestampStruct,
263263
public readonly obj: ITimestampStruct,
264264
public readonly ref: ITimestampStruct,
265-
public readonly data: string,
265+
public data: string,
266266
) {}
267267

268268
public span(): number {

0 commit comments

Comments
 (0)