From 3110bbfa65a821edf3a57e7b41f3fc399f830da3 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 20 Apr 2022 00:00:53 +0200 Subject: [PATCH 1/8] =?UTF-8?q?refactor(json-crdt):=20=F0=9F=92=A1=20use?= =?UTF-8?q?=20json=20encoding=20for=20JSON=20Patch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/json-patch/JsonPatch.ts | 16 ++--- src/json-crdt/json-patch/JsonPatchDraft.ts | 65 ++++++++++--------- .../json-patch/__tests__/JsonPatch.spec.ts | 4 +- 3 files changed, 43 insertions(+), 42 deletions(-) diff --git a/src/json-crdt/json-patch/JsonPatch.ts b/src/json-crdt/json-patch/JsonPatch.ts index 704eda057e..5b33668f82 100644 --- a/src/json-crdt/json-patch/JsonPatch.ts +++ b/src/json-crdt/json-patch/JsonPatch.ts @@ -1,27 +1,23 @@ -import type {Model} from '../model/Model'; -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'; +import type {Model} from '../model/Model'; +import type {Operation} from '../../json-patch'; +import type {Patch} from '../../json-crdt-patch/Patch'; export class JsonPatch { constructor(public readonly model: Model) {} - public createDraft(ops: Op[]): Draft { + public createDraft(ops: Operation[]): Draft { const draft = new JsonPatchDraft(this.model); draft.applyOps(ops); return draft; } - public createCrdtPatch(ops: Op[]): Patch { + public createCrdtPatch(ops: Operation[]): Patch { return this.createDraft(ops).patch(this.model.clock); } - public applyPatch(jsonPatch: JsonPatchOperation[], options: JsonPatchOptions) { - const ops = decode(jsonPatch, options); + public applyPatch(ops: Operation[]) { const patch = this.createCrdtPatch(ops); this.model.clock.tick(patch.span()); this.model.applyPatch(patch); diff --git a/src/json-crdt/json-patch/JsonPatchDraft.ts b/src/json-crdt/json-patch/JsonPatchDraft.ts index f4086159ad..23ea1b703c 100644 --- a/src/json-crdt/json-patch/JsonPatchDraft.ts +++ b/src/json-crdt/json-patch/JsonPatchDraft.ts @@ -1,33 +1,36 @@ -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} from '../../json-patch'; export class JsonPatchDraft extends Draft { constructor(public readonly model: Model) { super(); } - public applyOps(ops: Op[]) { + public applyOps(ops: Operation[]) { 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.applyOpRemove(op); break; + case 'replace': this.applyOpReplace(op); break; + case 'move': this.applyOpMove(op); break; + case 'copy': this.applyOpCopy(op); break; + case 'test': this.applyOpTest(op); break; + } } - public applyOpAdd(op: OpAdd): void { + public applyOpAdd(op: OperationAdd): void { const {builder} = this; - const steps = op.path; + const steps = toPath(op.path); if (!steps.length) this.setRoot(op.value); else { const objSteps = steps.slice(0, steps.length - 1); @@ -54,9 +57,9 @@ export class JsonPatchDraft extends Draft { } } - public applyOpRemove(op: OpRemove): void { + public applyOpRemove(op: OperationRemove): void { const {builder} = this; - const steps = op.path; + const steps = toPath(op.path); if (!steps.length) this.setRoot(null); else { const objSteps = steps.slice(0, steps.length - 1); @@ -76,30 +79,32 @@ export class JsonPatchDraft extends Draft { } } - public applyOpReplace(op: OpReplace): void { + public applyOpReplace(op: OperationReplace): void { const {path, value} = op; - this.applyOpRemove(new OpRemove(path, undefined)); - this.applyOpAdd(new OpAdd(path, value)); + this.applyOpRemove({op: 'remove', path}); + this.applyOpAdd({op: 'add', path, value}); } - public applyOpMove(op: OpMove): void { - const {path, from} = op; + public applyOpMove(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.applyOpRemove({op: 'remove', path: from}); + this.applyOpAdd({op: 'add', path, value: json}); } - public applyOpCopy(op: OpCopy): void { - const {path, from} = op; + public applyOpCopy(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 applyOpTest(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'); } private get(steps: Path): unknown { diff --git a/src/json-crdt/json-patch/__tests__/JsonPatch.spec.ts b/src/json-crdt/json-patch/__tests__/JsonPatch.spec.ts index fbb946cef0..4daa867119 100644 --- a/src/json-crdt/json-patch/__tests__/JsonPatch.spec.ts +++ b/src/json-crdt/json-patch/__tests__/JsonPatch.spec.ts @@ -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.applyPatch(patch); }).toThrow(new Error(throws)); } else { - for (const patch of patches) jsonPatch.applyPatch(patch, {}); + for (const patch of patches) jsonPatch.applyPatch(patch); expect(model.toView()).toEqual(doc2); } }); From b63cf8ae0efca2c6646a1411964f0ec2ccab8f19 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 20 Apr 2022 00:04:05 +0200 Subject: [PATCH 2/8] =?UTF-8?q?refactor(json-crdt):=20=F0=9F=92=A1=20use?= =?UTF-8?q?=20composition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/json-patch/JsonPatch.ts | 2 +- src/json-crdt/json-patch/JsonPatchDraft.ts | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/json-crdt/json-patch/JsonPatch.ts b/src/json-crdt/json-patch/JsonPatch.ts index 5b33668f82..dd37ecc5cf 100644 --- a/src/json-crdt/json-patch/JsonPatch.ts +++ b/src/json-crdt/json-patch/JsonPatch.ts @@ -10,7 +10,7 @@ export class JsonPatch { public createDraft(ops: Operation[]): Draft { const draft = new JsonPatchDraft(this.model); draft.applyOps(ops); - return draft; + return draft.draft; } public createCrdtPatch(ops: Operation[]): Patch { diff --git a/src/json-crdt/json-patch/JsonPatchDraft.ts b/src/json-crdt/json-patch/JsonPatchDraft.ts index 23ea1b703c..7019052479 100644 --- a/src/json-crdt/json-patch/JsonPatchDraft.ts +++ b/src/json-crdt/json-patch/JsonPatchDraft.ts @@ -8,12 +8,12 @@ import {toPath} from '../../json-pointer/util'; import type {Model} from '../model'; import type {Operation, OperationAdd, OperationRemove, OperationReplace, OperationMove, OperationCopy, OperationTest} 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: Operation[]) { + public applyOps(ops: Operation[]): void { for (const op of ops) this.applyOp(op); } @@ -29,7 +29,7 @@ export class JsonPatchDraft extends Draft { } public applyOpAdd(op: OperationAdd): void { - const {builder} = this; + const {builder} = this.draft; const steps = toPath(op.path); if (!steps.length) this.setRoot(op.value); else { @@ -58,7 +58,7 @@ export class JsonPatchDraft extends Draft { } public applyOpRemove(op: OperationRemove): void { - const {builder} = this; + const {builder} = this.draft; const steps = toPath(op.path); if (!steps.length) this.setRoot(null); else { @@ -132,7 +132,7 @@ export class JsonPatchDraft extends Draft { } private setRoot(json: unknown) { - const {builder} = this; + const {builder} = this.draft; builder.root(builder.json(json)); } } From 44ff2762d5894e47a5a5d4e5ac0bdba7bc1d310e Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 20 Apr 2022 00:04:33 +0200 Subject: [PATCH 3/8] =?UTF-8?q?refactor:=20=F0=9F=92=A1=20rename=20version?= =?UTF-8?q?=20to=20v?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-block/Block.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/json-block/Block.ts b/src/json-block/Block.ts index 5c689a702c..f95695d3a1 100644 --- a/src/json-block/Block.ts +++ b/src/json-block/Block.ts @@ -18,14 +18,14 @@ export interface BlockModel { } export class BasicBlock { - public version$: BehaviorSubject; + public v$: BehaviorSubject; constructor(version: number, public readonly model: BlockModel) { - this.version$ = new BehaviorSubject(version); + this.v$ = new BehaviorSubject(version); } public fork(): BasicBlock { - return new BasicBlock(this.version$.getValue(), this.model.fork()); + return new BasicBlock(this.v$.getValue(), this.model.fork()); } /** @@ -33,7 +33,7 @@ export class BasicBlock { * a function to allow for lazy evaluation. */ public data$(): Observable { - 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. */ @@ -43,7 +43,7 @@ export class BasicBlock { public apply(patch: Patch): void { this.model.apply(patch); - this.version$.next(this.version$.getValue() + 1); + this.v$.next(this.v$.getValue() + 1); } } @@ -83,7 +83,7 @@ export class Branch { public async merge(opts: BranchDependencies): Promise { 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); From 87188184a8dde0890eb9a3371a4ad0f8f03121b4 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 20 Apr 2022 00:26:32 +0200 Subject: [PATCH 4/8] =?UTF-8?q?feat(json-crdt):=20=F0=9F=8E=B8=20improve?= =?UTF-8?q?=20JSON=20Patch=20interfac?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/json-patch/JsonPatch.ts | 19 +++++++-------- src/json-crdt/json-patch/JsonPatchDraft.ts | 24 +++++++++---------- .../json-patch/__tests__/JsonPatch.spec.ts | 4 ++-- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/json-crdt/json-patch/JsonPatch.ts b/src/json-crdt/json-patch/JsonPatch.ts index dd37ecc5cf..3ee8adf710 100644 --- a/src/json-crdt/json-patch/JsonPatch.ts +++ b/src/json-crdt/json-patch/JsonPatch.ts @@ -5,21 +5,20 @@ import type {Operation} from '../../json-patch'; import type {Patch} from '../../json-crdt-patch/Patch'; export class JsonPatch { - constructor(public readonly model: Model) {} + protected draft = new JsonPatchDraft(this.model); - public createDraft(ops: Operation[]): Draft { - const draft = new JsonPatchDraft(this.model); - draft.applyOps(ops); - return draft.draft; - } + constructor(public readonly model: Model) {} - public createCrdtPatch(ops: Operation[]): Patch { - return this.createDraft(ops).patch(this.model.clock); + public apply(ops: Operation[]): this { + this.draft.applyOps(ops); + return this; } - public applyPatch(ops: Operation[]) { - 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; } } diff --git a/src/json-crdt/json-patch/JsonPatchDraft.ts b/src/json-crdt/json-patch/JsonPatchDraft.ts index 7019052479..c3f070bd4b 100644 --- a/src/json-crdt/json-patch/JsonPatchDraft.ts +++ b/src/json-crdt/json-patch/JsonPatchDraft.ts @@ -20,11 +20,11 @@ export class JsonPatchDraft { public applyOp(op: Operation): void { switch(op.op) { case 'add': this.applyOpAdd(op); break; - case 'remove': this.applyOpRemove(op); break; - case 'replace': this.applyOpReplace(op); break; - case 'move': this.applyOpMove(op); break; - case 'copy': this.applyOpCopy(op); break; - case 'test': this.applyOpTest(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; } } @@ -57,7 +57,7 @@ export class JsonPatchDraft { } } - public applyOpRemove(op: OperationRemove): void { + public applyRemove(op: OperationRemove): void { const {builder} = this.draft; const steps = toPath(op.path); if (!steps.length) this.setRoot(null); @@ -79,29 +79,29 @@ export class JsonPatchDraft { } } - public applyOpReplace(op: OperationReplace): void { + public applyReplace(op: OperationReplace): void { const {path, value} = op; - this.applyOpRemove({op: 'remove', path}); + this.applyRemove({op: 'remove', path}); this.applyOpAdd({op: 'add', path, value}); } - public applyOpMove(op: OperationMove): void { + 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({op: 'remove', path: from}); + this.applyRemove({op: 'remove', path: from}); this.applyOpAdd({op: 'add', path, value: json}); } - public applyOpCopy(op: OperationCopy): void { + public applyCopy(op: OperationCopy): void { const path = toPath(op.path); const from = toPath(op.from); const json = this.json(from); this.applyOpAdd({op: 'add', path, value: json}); } - public applyOpTest(op: OperationTest): void { + public applyTest(op: OperationTest): void { const path = toPath(op.path); const json = this.json(path); if (!deepEqual(json, op.value)) throw new Error('TEST'); diff --git a/src/json-crdt/json-patch/__tests__/JsonPatch.spec.ts b/src/json-crdt/json-patch/__tests__/JsonPatch.spec.ts index 4daa867119..bb2555b137 100644 --- a/src/json-crdt/json-patch/__tests__/JsonPatch.spec.ts +++ b/src/json-crdt/json-patch/__tests__/JsonPatch.spec.ts @@ -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); } }); From 858ae48ac8ac172bb8f77cedac62b7cb890490b6 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 20 Apr 2022 00:43:26 +0200 Subject: [PATCH 5/8] =?UTF-8?q?feat(json-crdt):=20=F0=9F=8E=B8=20add=20str?= =?UTF-8?q?=5Fins=20JSON=20Patch+=20operation=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/json-patch/JsonPatch.ts | 1 - src/json-crdt/json-patch/JsonPatchDraft.ts | 12 ++- .../__tests__/JsonPatch.str.spec.ts | 85 +++++++++++++++++++ src/json-crdt/types/rga-string/StringType.ts | 5 +- 4 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 src/json-crdt/json-patch/__tests__/JsonPatch.str.spec.ts diff --git a/src/json-crdt/json-patch/JsonPatch.ts b/src/json-crdt/json-patch/JsonPatch.ts index 3ee8adf710..a6524882a3 100644 --- a/src/json-crdt/json-patch/JsonPatch.ts +++ b/src/json-crdt/json-patch/JsonPatch.ts @@ -1,4 +1,3 @@ -import {Draft} from '../../json-crdt-patch/Draft'; import {JsonPatchDraft} from './JsonPatchDraft'; import type {Model} from '../model/Model'; import type {Operation} from '../../json-patch'; diff --git a/src/json-crdt/json-patch/JsonPatchDraft.ts b/src/json-crdt/json-patch/JsonPatchDraft.ts index c3f070bd4b..662d8af97d 100644 --- a/src/json-crdt/json-patch/JsonPatchDraft.ts +++ b/src/json-crdt/json-patch/JsonPatchDraft.ts @@ -6,7 +6,7 @@ import {ObjectType} from '../types/lww-object/ObjectType'; import {UNDEFINED_ID} from '../../json-crdt-patch/constants'; import {toPath} from '../../json-pointer/util'; import type {Model} from '../model'; -import type {Operation, OperationAdd, OperationRemove, OperationReplace, OperationMove, OperationCopy, OperationTest} from '../../json-patch'; +import type {Operation, OperationAdd, OperationRemove, OperationReplace, OperationMove, OperationCopy, OperationTest, OperationStrIns} from '../../json-patch'; export class JsonPatchDraft { public readonly draft = new Draft(); @@ -25,6 +25,7 @@ export class JsonPatchDraft { 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; } } @@ -107,6 +108,15 @@ export class JsonPatchDraft { 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); + } + private get(steps: Path): unknown { if (!steps.length) return this.model.toView(); else { diff --git a/src/json-crdt/json-patch/__tests__/JsonPatch.str.spec.ts b/src/json-crdt/json-patch/__tests__/JsonPatch.str.spec.ts new file mode 100644 index 0000000000..1674cf30a5 --- /dev/null +++ b/src/json-crdt/json-patch/__tests__/JsonPatch.str.spec.ts @@ -0,0 +1,85 @@ +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!'}]}, + }, +]; + +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); + } + }); +} diff --git a/src/json-crdt/types/rga-string/StringType.ts b/src/json-crdt/types/rga-string/StringType.ts index 12d435f5bb..3e29400d67 100644 --- a/src/json-crdt/types/rga-string/StringType.ts +++ b/src/json-crdt/types/rga-string/StringType.ts @@ -184,7 +184,10 @@ export class StringType implements JsonNode { return size; } - /** String length. */ + /** + * String length. + * @todo This could be cached same as .toJson(). + */ public length(): number { let curr: StringChunk | null = this.start; let size: number = 0; From 9b6112deb9615184a2836f064f09224de70ea11d Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 20 Apr 2022 00:56:54 +0200 Subject: [PATCH 6/8] =?UTF-8?q?feat(json-crdt):=20=F0=9F=8E=B8=20add=20JSO?= =?UTF-8?q?N=20Patch+=20str=5Fdel=20operation=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/json-patch/JsonPatchDraft.ts | 14 +++- .../__tests__/JsonPatch.str.spec.ts | 65 +++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/json-crdt/json-patch/JsonPatchDraft.ts b/src/json-crdt/json-patch/JsonPatchDraft.ts index 662d8af97d..2cc91efe7d 100644 --- a/src/json-crdt/json-patch/JsonPatchDraft.ts +++ b/src/json-crdt/json-patch/JsonPatchDraft.ts @@ -6,7 +6,7 @@ import {ObjectType} from '../types/lww-object/ObjectType'; import {UNDEFINED_ID} from '../../json-crdt-patch/constants'; import {toPath} from '../../json-pointer/util'; import type {Model} from '../model'; -import type {Operation, OperationAdd, OperationRemove, OperationReplace, OperationMove, OperationCopy, OperationTest, OperationStrIns} from '../../json-patch'; +import type {Operation, OperationAdd, OperationRemove, OperationReplace, OperationMove, OperationCopy, OperationTest, OperationStrIns, OperationStrDel} from '../../json-patch'; export class JsonPatchDraft { public readonly draft = new Draft(); @@ -26,6 +26,7 @@ export class JsonPatchDraft { 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; } } @@ -117,6 +118,17 @@ export class JsonPatchDraft { 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 { if (!steps.length) return this.model.toView(); else { diff --git a/src/json-crdt/json-patch/__tests__/JsonPatch.str.spec.ts b/src/json-crdt/json-patch/__tests__/JsonPatch.str.spec.ts index 1674cf30a5..1c6ef02219 100644 --- a/src/json-crdt/json-patch/__tests__/JsonPatch.str.spec.ts +++ b/src/json-crdt/json-patch/__tests__/JsonPatch.str.spec.ts @@ -66,6 +66,71 @@ const testCases: TestCase[] = [ ], 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) { From 919196bf059947121772c8b8539d8c88698dc34c Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 20 Apr 2022 01:01:38 +0200 Subject: [PATCH 7/8] =?UTF-8?q?feat(json-crdt):=20=F0=9F=8E=B8=20throw=20o?= =?UTF-8?q?n=20uknown=20op?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/json-patch/JsonPatchDraft.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/json-crdt/json-patch/JsonPatchDraft.ts b/src/json-crdt/json-patch/JsonPatchDraft.ts index 2cc91efe7d..091d8dc6e0 100644 --- a/src/json-crdt/json-patch/JsonPatchDraft.ts +++ b/src/json-crdt/json-patch/JsonPatchDraft.ts @@ -27,6 +27,7 @@ export class JsonPatchDraft { 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'); } } From 7076cfe8d6e507348195bdcb366d74c9376f996d Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 20 Apr 2022 01:03:18 +0200 Subject: [PATCH 8/8] =?UTF-8?q?fix(json-crdt):=20=F0=9F=90=9B=20use=20this?= =?UTF-8?q?.model=20after=20it=20is=20defined?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/json-patch/JsonPatch.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/json-crdt/json-patch/JsonPatch.ts b/src/json-crdt/json-patch/JsonPatch.ts index a6524882a3..401c5083a9 100644 --- a/src/json-crdt/json-patch/JsonPatch.ts +++ b/src/json-crdt/json-patch/JsonPatch.ts @@ -4,9 +4,11 @@ import type {Operation} from '../../json-patch'; import type {Patch} from '../../json-crdt-patch/Patch'; export class JsonPatch { - protected draft = new JsonPatchDraft(this.model); + protected draft: JsonPatchDraft; - constructor(public readonly model: Model) {} + constructor(public readonly model: Model) { + this.draft = new JsonPatchDraft(this.model); + } public apply(ops: Operation[]): this { this.draft.applyOps(ops);