Skip to content

Commit 5c17099

Browse files
authored
Merge pull request #163 from streamich/json-crdt-json-patch
JSON CRDT JSON Patch+ API
2 parents 4cc3435 + 7076cfe commit 5c17099

File tree

6 files changed

+239
-62
lines changed

6 files changed

+239
-62
lines changed

src/json-block/Block.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,22 @@ export interface BlockModel<Data, Patch> {
1818
}
1919

2020
export class BasicBlock<Data, Patch> {
21-
public version$: BehaviorSubject<number>;
21+
public v$: BehaviorSubject<number>;
2222

2323
constructor(version: number, public readonly model: BlockModel<Data, Patch>) {
24-
this.version$ = new BehaviorSubject<number>(version);
24+
this.v$ = new BehaviorSubject<number>(version);
2525
}
2626

2727
public fork(): BasicBlock<Data, Patch> {
28-
return new BasicBlock<Data, Patch>(this.version$.getValue(), this.model.fork());
28+
return new BasicBlock<Data, Patch>(this.v$.getValue(), this.model.fork());
2929
}
3030

3131
/**
3232
* Observable of the latest data value of this block. It is wrapped in
3333
* a function to allow for lazy evaluation.
3434
*/
3535
public data$(): Observable<Data> {
36-
return this.version$.pipe(switchMap(() => of(this.model.getData())));
36+
return this.v$.pipe(switchMap(() => of(this.model.getData())));
3737
}
3838

3939
/** Get the latest value of the block. */
@@ -43,7 +43,7 @@ export class BasicBlock<Data, Patch> {
4343

4444
public apply(patch: Patch): void {
4545
this.model.apply(patch);
46-
this.version$.next(this.version$.getValue() + 1);
46+
this.v$.next(this.v$.getValue() + 1);
4747
}
4848
}
4949

@@ -83,7 +83,7 @@ export class Branch<Data, Patch> {
8383
public async merge(opts: BranchDependencies<Patch>): Promise<void> {
8484
try {
8585
const base = this.base$.getValue();
86-
const baseVersion = base.version$.getValue();
86+
const baseVersion = base.v$.getValue();
8787
const batch = [...this.patches];
8888
this.merging = batch.length;
8989
const res = await opts.merge(baseVersion, batch);
Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,25 @@
1+
import {JsonPatchDraft} from './JsonPatchDraft';
12
import type {Model} from '../model/Model';
3+
import type {Operation} from '../../json-patch';
24
import type {Patch} from '../../json-crdt-patch/Patch';
3-
import type {Operation as JsonPatchOperation} from '../../json-patch';
4-
import {Draft} from '../../json-crdt-patch/Draft';
5-
import {Op} from '../../json-patch/op';
6-
import {JsonPatchDraft} from './JsonPatchDraft';
7-
import {decode} from '../../json-patch/codec/json';
8-
import type {JsonPatchOptions} from '../../json-patch/types';
95

106
export class JsonPatch {
11-
constructor(public readonly model: Model) {}
7+
protected draft: JsonPatchDraft;
128

13-
public createDraft(ops: Op[]): Draft {
14-
const draft = new JsonPatchDraft(this.model);
15-
draft.applyOps(ops);
16-
return draft;
9+
constructor(public readonly model: Model) {
10+
this.draft = new JsonPatchDraft(this.model);
1711
}
1812

19-
public createCrdtPatch(ops: Op[]): Patch {
20-
return this.createDraft(ops).patch(this.model.clock);
13+
public apply(ops: Operation[]): this {
14+
this.draft.applyOps(ops);
15+
return this;
2116
}
2217

23-
public applyPatch(jsonPatch: JsonPatchOperation[], options: JsonPatchOptions) {
24-
const ops = decode(jsonPatch, options);
25-
const patch = this.createCrdtPatch(ops);
18+
public commit(): Patch {
19+
const patch = this.draft.draft.patch(this.model.clock);
2620
this.model.clock.tick(patch.span());
2721
this.model.applyPatch(patch);
22+
this.draft = new JsonPatchDraft(this.model);
23+
return patch;
2824
}
2925
}

src/json-crdt/json-patch/JsonPatchDraft.ts

Lines changed: 65 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,39 @@
1-
import type {Model} from '../model';
1+
import {ArrayType} from '../types/rga-array/ArrayType';
2+
import {deepEqual} from '../../json-equal/deepEqual';
23
import {Draft} from '../../json-crdt-patch/Draft';
3-
import {Op, OpAdd, OpCopy, OpMove, OpRemove, OpReplace, OpTest} from '../../json-patch/op';
4+
import {isChild, Path} from '../../json-pointer';
45
import {ObjectType} from '../types/lww-object/ObjectType';
5-
import {ArrayType} from '../types/rga-array/ArrayType';
66
import {UNDEFINED_ID} from '../../json-crdt-patch/constants';
7-
import {isChild, Path} from '../../json-pointer';
8-
import {deepEqual} from '../../json-equal/deepEqual';
7+
import {toPath} from '../../json-pointer/util';
8+
import type {Model} from '../model';
9+
import type {Operation, OperationAdd, OperationRemove, OperationReplace, OperationMove, OperationCopy, OperationTest, OperationStrIns, OperationStrDel} from '../../json-patch';
910

10-
export class JsonPatchDraft extends Draft {
11-
constructor(public readonly model: Model) {
12-
super();
13-
}
11+
export class JsonPatchDraft {
12+
public readonly draft = new Draft();
13+
14+
constructor(public readonly model: Model) {}
1415

15-
public applyOps(ops: Op[]) {
16+
public applyOps(ops: Operation[]): void {
1617
for (const op of ops) this.applyOp(op);
1718
}
1819

19-
public applyOp(op: Op): void {
20-
if (op instanceof OpAdd) this.applyOpAdd(op);
21-
else if (op instanceof OpRemove) this.applyOpRemove(op);
22-
else if (op instanceof OpReplace) this.applyOpReplace(op);
23-
else if (op instanceof OpMove) this.applyOpMove(op);
24-
else if (op instanceof OpCopy) this.applyOpCopy(op);
25-
else if (op instanceof OpTest) this.applyOpTest(op);
20+
public applyOp(op: Operation): void {
21+
switch(op.op) {
22+
case 'add': this.applyOpAdd(op); break;
23+
case 'remove': this.applyRemove(op); break;
24+
case 'replace': this.applyReplace(op); break;
25+
case 'move': this.applyMove(op); break;
26+
case 'copy': this.applyCopy(op); break;
27+
case 'test': this.applyTest(op); break;
28+
case 'str_ins': this.applyStrIns(op); break;
29+
case 'str_del': this.applyStrDel(op); break;
30+
default: throw new Error('UNKNOWN_OP');
31+
}
2632
}
2733

28-
public applyOpAdd(op: OpAdd): void {
29-
const {builder} = this;
30-
const steps = op.path;
34+
public applyOpAdd(op: OperationAdd): void {
35+
const {builder} = this.draft;
36+
const steps = toPath(op.path);
3137
if (!steps.length) this.setRoot(op.value);
3238
else {
3339
const objSteps = steps.slice(0, steps.length - 1);
@@ -54,9 +60,9 @@ export class JsonPatchDraft extends Draft {
5460
}
5561
}
5662

57-
public applyOpRemove(op: OpRemove): void {
58-
const {builder} = this;
59-
const steps = op.path;
63+
public applyRemove(op: OperationRemove): void {
64+
const {builder} = this.draft;
65+
const steps = toPath(op.path);
6066
if (!steps.length) this.setRoot(null);
6167
else {
6268
const objSteps = steps.slice(0, steps.length - 1);
@@ -76,30 +82,52 @@ export class JsonPatchDraft extends Draft {
7682
}
7783
}
7884

79-
public applyOpReplace(op: OpReplace): void {
85+
public applyReplace(op: OperationReplace): void {
8086
const {path, value} = op;
81-
this.applyOpRemove(new OpRemove(path, undefined));
82-
this.applyOpAdd(new OpAdd(path, value));
87+
this.applyRemove({op: 'remove', path});
88+
this.applyOpAdd({op: 'add', path, value});
8389
}
8490

85-
public applyOpMove(op: OpMove): void {
86-
const {path, from} = op;
91+
public applyMove(op: OperationMove): void {
92+
const path = toPath(op.path);
93+
const from = toPath(op.from);
8794
if (isChild(from, path)) throw new Error('INVALID_CHILD');
8895
const json = this.json(from);
89-
this.applyOpRemove(new OpRemove(from, undefined));
90-
this.applyOpAdd(new OpAdd(path, json));
96+
this.applyRemove({op: 'remove', path: from});
97+
this.applyOpAdd({op: 'add', path, value: json});
9198
}
9299

93-
public applyOpCopy(op: OpCopy): void {
94-
const {path, from} = op;
100+
public applyCopy(op: OperationCopy): void {
101+
const path = toPath(op.path);
102+
const from = toPath(op.from);
95103
const json = this.json(from);
96-
this.applyOpAdd(new OpAdd(path, json));
104+
this.applyOpAdd({op: 'add', path, value: json});
97105
}
98106

99-
public applyOpTest(op: OpTest): void {
100-
const {path, value} = op;
107+
public applyTest(op: OperationTest): void {
108+
const path = toPath(op.path);
101109
const json = this.json(path);
102-
if (!deepEqual(json, value)) throw new Error('TEST');
110+
if (!deepEqual(json, op.value)) throw new Error('TEST');
111+
}
112+
113+
public applyStrIns(op: OperationStrIns): void {
114+
const path = toPath(op.path);
115+
const {node} = this.model.api.str(path);
116+
const {builder} = this.draft;
117+
const length = node.length();
118+
const after = op.pos ? node.findId(length < op.pos ? length - 1 : op.pos - 1) : node.id;
119+
builder.insStr(node.id, after, op.str);
120+
}
121+
122+
public applyStrDel(op: OperationStrDel): void {
123+
const path = toPath(op.path);
124+
const {node} = this.model.api.str(path);
125+
const {builder} = this.draft;
126+
const length = node.length();
127+
if (length <= op.pos) return;
128+
const after = node.findId(op.pos);
129+
const deletionLength = Math.min(op.len ?? op.str!.length, length - op.pos);
130+
builder.del(node.id, after, deletionLength);
103131
}
104132

105133
private get(steps: Path): unknown {
@@ -127,7 +155,7 @@ export class JsonPatchDraft extends Draft {
127155
}
128156

129157
private setRoot(json: unknown) {
130-
const {builder} = this;
158+
const {builder} = this.draft;
131159
builder.root(builder.json(json));
132160
}
133161
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -353,10 +353,10 @@ for (const {only, name, doc1, doc2, patches, throws} of testCases) {
353353
if (doc1 !== undefined) model.api.root(doc1).commit();
354354
if (throws) {
355355
expect(() => {
356-
for (const patch of patches) jsonPatch.applyPatch(patch, {});
356+
for (const patch of patches) jsonPatch.apply(patch).commit();
357357
}).toThrow(new Error(throws));
358358
} else {
359-
for (const patch of patches) jsonPatch.applyPatch(patch, {});
359+
for (const patch of patches) jsonPatch.apply(patch).commit();
360360
expect(model.toView()).toEqual(doc2);
361361
}
362362
});
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import {Operation} from '../../../json-patch';
2+
import {Model} from '../../model/Model';
3+
import {JsonPatch} from '../JsonPatch';
4+
5+
interface TestCase {
6+
name: string;
7+
doc1?: unknown;
8+
patches: Operation[][];
9+
doc2?: unknown;
10+
throws?: string;
11+
only?: true;
12+
}
13+
14+
const testCases: TestCase[] = [
15+
{
16+
name: 'can insert char in empty string',
17+
doc1: '',
18+
patches: [[{op: 'str_ins', path: '', pos: 0, str: 'a'}]],
19+
doc2: 'a',
20+
},
21+
{
22+
name: 'can insert char at the end of string',
23+
doc1: '1',
24+
patches: [[{op: 'str_ins', path: '', pos: 1, str: '2'}]],
25+
doc2: '12',
26+
},
27+
{
28+
name: 'can insert char beyond end of string',
29+
doc1: '1',
30+
patches: [[{op: 'str_ins', path: '', pos: 111, str: '2'}]],
31+
doc2: '12',
32+
},
33+
{
34+
name: 'can insert char beyond end of string - 2',
35+
doc1: '1',
36+
patches: [[{op: 'str_ins', path: '', pos: 2, str: '2'}]],
37+
doc2: '12',
38+
},
39+
{
40+
name: 'can insert char at the beginning of string',
41+
doc1: '1',
42+
patches: [[{op: 'str_ins', path: '', pos: 0, str: '0'}]],
43+
doc2: '01',
44+
},
45+
{
46+
name: 'can insert char in the middle of string',
47+
doc1: '25',
48+
patches: [[{op: 'str_ins', path: '', pos: 1, str: '.'}]],
49+
doc2: '2.5',
50+
},
51+
{
52+
name: 'can insert text in nested object',
53+
doc1: null,
54+
patches: [
55+
[{op: 'add', path: '', value: {foo: [{bar: 'baz'}]}}],
56+
[{op: 'str_ins', path: '/foo/0/bar', pos: 3, str: '!'}]
57+
],
58+
doc2: {foo: [{bar: 'baz!'}]},
59+
},
60+
{
61+
name: 'can insert text in nested object - 2',
62+
doc1: null,
63+
patches: [
64+
[{op: 'add', path: '', value: {foo: [{bar: 'baz'}]}}],
65+
[{op: 'str_ins', path: ['foo', 0, 'bar'], pos: 3, str: '!'}]
66+
],
67+
doc2: {foo: [{bar: 'baz!'}]},
68+
},
69+
{
70+
name: 'can delete a single char',
71+
doc1: 'a',
72+
patches: [
73+
[{op: 'str_del', path: [], pos: 0, len: 1}]
74+
],
75+
doc2: '',
76+
},
77+
{
78+
name: 'can delete from already empty string',
79+
doc1: '',
80+
patches: [
81+
[{op: 'str_del', path: [], pos: 0, len: 1}]
82+
],
83+
doc2: '',
84+
},
85+
{
86+
name: 'can delete at the end of string',
87+
doc1: 'ab',
88+
patches: [
89+
[{op: 'str_del', path: [], pos: 1, len: 1}]
90+
],
91+
doc2: 'a',
92+
},
93+
{
94+
name: 'can delete at the beginning of string',
95+
doc1: 'ab',
96+
patches: [
97+
[{op: 'str_del', path: [], pos: 0, len: 1}]
98+
],
99+
doc2: 'b',
100+
},
101+
{
102+
name: 'can delete in the middle of string',
103+
doc1: 'abc',
104+
patches: [
105+
[{op: 'str_del', path: [], pos: 1, len: 1}]
106+
],
107+
doc2: 'ac',
108+
},
109+
{
110+
name: 'can delete multiple chars',
111+
doc1: '1234',
112+
patches: [
113+
[{op: 'str_del', path: [], pos: 1, len: 2}],
114+
[{op: 'str_del', path: [], pos: 1, len: 5}],
115+
],
116+
doc2: '1',
117+
},
118+
{
119+
name: 'handles deletion beyond end of string',
120+
doc1: '1234',
121+
patches: [
122+
[{op: 'str_del', path: [], pos: 1111, len: 2}],
123+
],
124+
doc2: '1234',
125+
},
126+
{
127+
name: 'can delete a string in object',
128+
doc1: {foo: '123'},
129+
patches: [
130+
[{op: 'str_del', path: '/foo', pos: 1, len: 2}],
131+
],
132+
doc2: {foo: '1'},
133+
},
134+
];
135+
136+
for (const {only, name, doc1, doc2, patches, throws} of testCases) {
137+
(only ? test.only : test)(name, () => {
138+
const model = Model.withLogicalClock();
139+
const jsonPatch = new JsonPatch(model);
140+
if (doc1 !== undefined) model.api.root(doc1).commit();
141+
if (throws) {
142+
expect(() => {
143+
for (const patch of patches) jsonPatch.apply(patch).commit();
144+
}).toThrow(new Error(throws));
145+
} else {
146+
for (const patch of patches) jsonPatch.apply(patch).commit();
147+
expect(model.toView()).toEqual(doc2);
148+
}
149+
});
150+
}

0 commit comments

Comments
 (0)