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); diff --git a/src/json-crdt/json-patch/JsonPatch.ts b/src/json-crdt/json-patch/JsonPatch.ts index 704eda057e..401c5083a9 100644 --- a/src/json-crdt/json-patch/JsonPatch.ts +++ b/src/json-crdt/json-patch/JsonPatch.ts @@ -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; } } diff --git a/src/json-crdt/json-patch/JsonPatchDraft.ts b/src/json-crdt/json-patch/JsonPatchDraft.ts index f4086159ad..091d8dc6e0 100644 --- a/src/json-crdt/json-patch/JsonPatchDraft.ts +++ b/src/json-crdt/json-patch/JsonPatchDraft.ts @@ -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); @@ -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); @@ -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 { @@ -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)); } } diff --git a/src/json-crdt/json-patch/__tests__/JsonPatch.spec.ts b/src/json-crdt/json-patch/__tests__/JsonPatch.spec.ts index fbb946cef0..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); } }); 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..1c6ef02219 --- /dev/null +++ b/src/json-crdt/json-patch/__tests__/JsonPatch.str.spec.ts @@ -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); + } + }); +} 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;