Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 6 additions & 6 deletions src/json-block/Block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,22 @@ export interface BlockModel<Data, Patch> {
}

export class BasicBlock<Data, Patch> {
public version$: BehaviorSubject<number>;
public v$: BehaviorSubject<number>;

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

public fork(): BasicBlock<Data, Patch> {
return new BasicBlock<Data, Patch>(this.version$.getValue(), this.model.fork());
return new BasicBlock<Data, Patch>(this.v$.getValue(), this.model.fork());
}

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

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

public apply(patch: Patch): void {
this.model.apply(patch);
this.version$.next(this.version$.getValue() + 1);
this.v$.next(this.v$.getValue() + 1);
}
}

Expand Down Expand Up @@ -83,7 +83,7 @@ export class Branch<Data, Patch> {
public async merge(opts: BranchDependencies<Patch>): Promise<void> {
try {
const base = this.base$.getValue();
const baseVersion = base.version$.getValue();
const baseVersion = base.v$.getValue();
const batch = [...this.patches];
this.merging = batch.length;
const res = await opts.merge(baseVersion, batch);
Expand Down
28 changes: 12 additions & 16 deletions src/json-crdt/json-patch/JsonPatch.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,25 @@
import {JsonPatchDraft} from './JsonPatchDraft';
import type {Model} from '../model/Model';
import type {Operation} from '../../json-patch';
import type {Patch} from '../../json-crdt-patch/Patch';
import type {Operation as JsonPatchOperation} from '../../json-patch';
import {Draft} from '../../json-crdt-patch/Draft';
import {Op} from '../../json-patch/op';
import {JsonPatchDraft} from './JsonPatchDraft';
import {decode} from '../../json-patch/codec/json';
import type {JsonPatchOptions} from '../../json-patch/types';

export class JsonPatch {
constructor(public readonly model: Model) {}
protected draft: JsonPatchDraft;

public createDraft(ops: Op[]): Draft {
const draft = new JsonPatchDraft(this.model);
draft.applyOps(ops);
return draft;
constructor(public readonly model: Model) {
this.draft = new JsonPatchDraft(this.model);
}

public createCrdtPatch(ops: Op[]): Patch {
return this.createDraft(ops).patch(this.model.clock);
public apply(ops: Operation[]): this {
this.draft.applyOps(ops);
return this;
}

public applyPatch(jsonPatch: JsonPatchOperation[], options: JsonPatchOptions) {
const ops = decode(jsonPatch, options);
const patch = this.createCrdtPatch(ops);
public commit(): Patch {
const patch = this.draft.draft.patch(this.model.clock);
this.model.clock.tick(patch.span());
this.model.applyPatch(patch);
this.draft = new JsonPatchDraft(this.model);
return patch;
}
}
102 changes: 65 additions & 37 deletions src/json-crdt/json-patch/JsonPatchDraft.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,39 @@
import type {Model} from '../model';
import {ArrayType} from '../types/rga-array/ArrayType';
import {deepEqual} from '../../json-equal/deepEqual';
import {Draft} from '../../json-crdt-patch/Draft';
import {Op, OpAdd, OpCopy, OpMove, OpRemove, OpReplace, OpTest} from '../../json-patch/op';
import {isChild, Path} from '../../json-pointer';
import {ObjectType} from '../types/lww-object/ObjectType';
import {ArrayType} from '../types/rga-array/ArrayType';
import {UNDEFINED_ID} from '../../json-crdt-patch/constants';
import {isChild, Path} from '../../json-pointer';
import {deepEqual} from '../../json-equal/deepEqual';
import {toPath} from '../../json-pointer/util';
import type {Model} from '../model';
import type {Operation, OperationAdd, OperationRemove, OperationReplace, OperationMove, OperationCopy, OperationTest, OperationStrIns, OperationStrDel} from '../../json-patch';

export class JsonPatchDraft extends Draft {
constructor(public readonly model: Model) {
super();
}
export class JsonPatchDraft {
public readonly draft = new Draft();

constructor(public readonly model: Model) {}

public applyOps(ops: Op[]) {
public applyOps(ops: Operation[]): void {
for (const op of ops) this.applyOp(op);
}

public applyOp(op: Op): void {
if (op instanceof OpAdd) this.applyOpAdd(op);
else if (op instanceof OpRemove) this.applyOpRemove(op);
else if (op instanceof OpReplace) this.applyOpReplace(op);
else if (op instanceof OpMove) this.applyOpMove(op);
else if (op instanceof OpCopy) this.applyOpCopy(op);
else if (op instanceof OpTest) this.applyOpTest(op);
public applyOp(op: Operation): void {
switch(op.op) {
case 'add': this.applyOpAdd(op); break;
case 'remove': this.applyRemove(op); break;
case 'replace': this.applyReplace(op); break;
case 'move': this.applyMove(op); break;
case 'copy': this.applyCopy(op); break;
case 'test': this.applyTest(op); break;
case 'str_ins': this.applyStrIns(op); break;
case 'str_del': this.applyStrDel(op); break;
default: throw new Error('UNKNOWN_OP');
}
}

public applyOpAdd(op: OpAdd): void {
const {builder} = this;
const steps = op.path;
public applyOpAdd(op: OperationAdd): void {
const {builder} = this.draft;
const steps = toPath(op.path);
if (!steps.length) this.setRoot(op.value);
else {
const objSteps = steps.slice(0, steps.length - 1);
Expand All @@ -54,9 +60,9 @@ export class JsonPatchDraft extends Draft {
}
}

public applyOpRemove(op: OpRemove): void {
const {builder} = this;
const steps = op.path;
public applyRemove(op: OperationRemove): void {
const {builder} = this.draft;
const steps = toPath(op.path);
if (!steps.length) this.setRoot(null);
else {
const objSteps = steps.slice(0, steps.length - 1);
Expand All @@ -76,30 +82,52 @@ export class JsonPatchDraft extends Draft {
}
}

public applyOpReplace(op: OpReplace): void {
public applyReplace(op: OperationReplace): void {
const {path, value} = op;
this.applyOpRemove(new OpRemove(path, undefined));
this.applyOpAdd(new OpAdd(path, value));
this.applyRemove({op: 'remove', path});
this.applyOpAdd({op: 'add', path, value});
}

public applyOpMove(op: OpMove): void {
const {path, from} = op;
public applyMove(op: OperationMove): void {
const path = toPath(op.path);
const from = toPath(op.from);
if (isChild(from, path)) throw new Error('INVALID_CHILD');
const json = this.json(from);
this.applyOpRemove(new OpRemove(from, undefined));
this.applyOpAdd(new OpAdd(path, json));
this.applyRemove({op: 'remove', path: from});
this.applyOpAdd({op: 'add', path, value: json});
}

public applyOpCopy(op: OpCopy): void {
const {path, from} = op;
public applyCopy(op: OperationCopy): void {
const path = toPath(op.path);
const from = toPath(op.from);
const json = this.json(from);
this.applyOpAdd(new OpAdd(path, json));
this.applyOpAdd({op: 'add', path, value: json});
}

public applyOpTest(op: OpTest): void {
const {path, value} = op;
public applyTest(op: OperationTest): void {
const path = toPath(op.path);
const json = this.json(path);
if (!deepEqual(json, value)) throw new Error('TEST');
if (!deepEqual(json, op.value)) throw new Error('TEST');
}

public applyStrIns(op: OperationStrIns): void {
const path = toPath(op.path);
const {node} = this.model.api.str(path);
const {builder} = this.draft;
const length = node.length();
const after = op.pos ? node.findId(length < op.pos ? length - 1 : op.pos - 1) : node.id;
builder.insStr(node.id, after, op.str);
}

public applyStrDel(op: OperationStrDel): void {
const path = toPath(op.path);
const {node} = this.model.api.str(path);
const {builder} = this.draft;
const length = node.length();
if (length <= op.pos) return;
const after = node.findId(op.pos);
const deletionLength = Math.min(op.len ?? op.str!.length, length - op.pos);
builder.del(node.id, after, deletionLength);
}

private get(steps: Path): unknown {
Expand Down Expand Up @@ -127,7 +155,7 @@ export class JsonPatchDraft extends Draft {
}

private setRoot(json: unknown) {
const {builder} = this;
const {builder} = this.draft;
builder.root(builder.json(json));
}
}
4 changes: 2 additions & 2 deletions src/json-crdt/json-patch/__tests__/JsonPatch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,10 +353,10 @@ for (const {only, name, doc1, doc2, patches, throws} of testCases) {
if (doc1 !== undefined) model.api.root(doc1).commit();
if (throws) {
expect(() => {
for (const patch of patches) jsonPatch.applyPatch(patch, {});
for (const patch of patches) jsonPatch.apply(patch).commit();
}).toThrow(new Error(throws));
} else {
for (const patch of patches) jsonPatch.applyPatch(patch, {});
for (const patch of patches) jsonPatch.apply(patch).commit();
expect(model.toView()).toEqual(doc2);
}
});
Expand Down
150 changes: 150 additions & 0 deletions src/json-crdt/json-patch/__tests__/JsonPatch.str.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import {Operation} from '../../../json-patch';
import {Model} from '../../model/Model';
import {JsonPatch} from '../JsonPatch';

interface TestCase {
name: string;
doc1?: unknown;
patches: Operation[][];
doc2?: unknown;
throws?: string;
only?: true;
}

const testCases: TestCase[] = [
{
name: 'can insert char in empty string',
doc1: '',
patches: [[{op: 'str_ins', path: '', pos: 0, str: 'a'}]],
doc2: 'a',
},
{
name: 'can insert char at the end of string',
doc1: '1',
patches: [[{op: 'str_ins', path: '', pos: 1, str: '2'}]],
doc2: '12',
},
{
name: 'can insert char beyond end of string',
doc1: '1',
patches: [[{op: 'str_ins', path: '', pos: 111, str: '2'}]],
doc2: '12',
},
{
name: 'can insert char beyond end of string - 2',
doc1: '1',
patches: [[{op: 'str_ins', path: '', pos: 2, str: '2'}]],
doc2: '12',
},
{
name: 'can insert char at the beginning of string',
doc1: '1',
patches: [[{op: 'str_ins', path: '', pos: 0, str: '0'}]],
doc2: '01',
},
{
name: 'can insert char in the middle of string',
doc1: '25',
patches: [[{op: 'str_ins', path: '', pos: 1, str: '.'}]],
doc2: '2.5',
},
{
name: 'can insert text in nested object',
doc1: null,
patches: [
[{op: 'add', path: '', value: {foo: [{bar: 'baz'}]}}],
[{op: 'str_ins', path: '/foo/0/bar', pos: 3, str: '!'}]
],
doc2: {foo: [{bar: 'baz!'}]},
},
{
name: 'can insert text in nested object - 2',
doc1: null,
patches: [
[{op: 'add', path: '', value: {foo: [{bar: 'baz'}]}}],
[{op: 'str_ins', path: ['foo', 0, 'bar'], pos: 3, str: '!'}]
],
doc2: {foo: [{bar: 'baz!'}]},
},
{
name: 'can delete a single char',
doc1: 'a',
patches: [
[{op: 'str_del', path: [], pos: 0, len: 1}]
],
doc2: '',
},
{
name: 'can delete from already empty string',
doc1: '',
patches: [
[{op: 'str_del', path: [], pos: 0, len: 1}]
],
doc2: '',
},
{
name: 'can delete at the end of string',
doc1: 'ab',
patches: [
[{op: 'str_del', path: [], pos: 1, len: 1}]
],
doc2: 'a',
},
{
name: 'can delete at the beginning of string',
doc1: 'ab',
patches: [
[{op: 'str_del', path: [], pos: 0, len: 1}]
],
doc2: 'b',
},
{
name: 'can delete in the middle of string',
doc1: 'abc',
patches: [
[{op: 'str_del', path: [], pos: 1, len: 1}]
],
doc2: 'ac',
},
{
name: 'can delete multiple chars',
doc1: '1234',
patches: [
[{op: 'str_del', path: [], pos: 1, len: 2}],
[{op: 'str_del', path: [], pos: 1, len: 5}],
],
doc2: '1',
},
{
name: 'handles deletion beyond end of string',
doc1: '1234',
patches: [
[{op: 'str_del', path: [], pos: 1111, len: 2}],
],
doc2: '1234',
},
{
name: 'can delete a string in object',
doc1: {foo: '123'},
patches: [
[{op: 'str_del', path: '/foo', pos: 1, len: 2}],
],
doc2: {foo: '1'},
},
];

for (const {only, name, doc1, doc2, patches, throws} of testCases) {
(only ? test.only : test)(name, () => {
const model = Model.withLogicalClock();
const jsonPatch = new JsonPatch(model);
if (doc1 !== undefined) model.api.root(doc1).commit();
if (throws) {
expect(() => {
for (const patch of patches) jsonPatch.apply(patch).commit();
}).toThrow(new Error(throws));
} else {
for (const patch of patches) jsonPatch.apply(patch).commit();
expect(model.toView()).toEqual(doc2);
}
});
}
Loading