UNPKG

json-joy

Version:

Collection of libraries for building collaborative editing apps.

415 lines (414 loc) 12.1 kB
import { NewConOp, NewObjOp, NewValOp, NewVecOp, NewStrOp, NewBinOp, NewArrOp, InsValOp, InsObjOp, InsVecOp, InsStrOp, InsBinOp, InsArrOp, DelOp, NopOp, } from './operations'; import { ts, Timestamp } from './clock'; import { isUint8Array } from '@jsonjoy.com/util/lib/buffers/isUint8Array'; import { Patch } from './Patch'; import { ORIGIN } from './constants'; import { VectorDelayedValue } from './builder/Tuple'; import { Konst } from './builder/Konst'; import { NodeBuilder } from './builder/DelayedValueBuilder'; const maybeConst = (x) => { switch (typeof x) { case 'number': case 'boolean': return true; default: return x === null; } }; /** * Utility class that helps in Patch construction. * * @category Patch */ export class PatchBuilder { clock; /** The patch being constructed. */ patch; /** * Creates a new PatchBuilder instance. * * @param clock Clock to use for generating timestamps. */ constructor(clock) { this.clock = clock; this.patch = new Patch(); } /** * Retrieve the sequence number of the next timestamp. * * @returns The next timestamp sequence number that will be used by the builder. */ nextTime() { return this.patch.nextTime() || this.clock.time; } /** * Returns the current {@link Patch} instance and resets the builder. * * @returns A new {@link Patch} instance containing all operations created * using this builder. */ flush() { const patch = this.patch; this.patch = new Patch(); return patch; } // --------------------------------------------------------- Basic operations /** * Create a new "obj" LWW-Map object. * * @returns ID of the new operation. */ obj() { this.pad(); const id = this.clock.tick(1); this.patch.ops.push(new NewObjOp(id)); return id; } /** * Create a new "arr" RGA-Array object. * * @returns ID of the new operation. */ arr() { this.pad(); const id = this.clock.tick(1); this.patch.ops.push(new NewArrOp(id)); return id; } /** * Create a new "vec" LWW-Array vector. * * @returns ID of the new operation. */ vec() { this.pad(); const id = this.clock.tick(1); this.patch.ops.push(new NewVecOp(id)); return id; } /** * Create a new "str" RGA-String object. * * @returns ID of the new operation. */ str() { this.pad(); const id = this.clock.tick(1); this.patch.ops.push(new NewStrOp(id)); return id; } /** * Create a new "bin" RGA-Binary object. * * @returns ID of the new operation. */ bin() { this.pad(); const id = this.clock.tick(1); this.patch.ops.push(new NewBinOp(id)); return id; } /** * Create a new immutable constant JSON value. Can be anything, including * nested arrays and objects. * * @param value JSON value * @returns ID of the new operation. */ const(value) { this.pad(); const id = this.clock.tick(1); this.patch.ops.push(new NewConOp(id, value)); return id; } /** * Create a new "val" LWW-Register object. Can be anything, including * nested arrays and objects. * * @param val Reference to another object. * @returns ID of the new operation. * @todo Rename to `newVal`. */ val() { this.pad(); const id = this.clock.tick(1); this.patch.ops.push(new NewValOp(id)); return id; } /** * Set value of document's root LWW-Register. * * @returns ID of the new operation. */ root(val) { this.pad(); const id = this.clock.tick(1); this.patch.ops.push(new InsValOp(id, ORIGIN, val)); return id; } /** * Set fields of an "obj" object. * * @returns ID of the new operation. */ insObj(obj, data) { this.pad(); if (!data.length) throw new Error('EMPTY_TUPLES'); const id = this.clock.tick(1); const op = new InsObjOp(id, obj, data); const span = op.span(); if (span > 1) this.clock.tick(span - 1); this.patch.ops.push(op); return id; } /** * Set elements of a "vec" object. * * @returns ID of the new operation. */ insVec(obj, data) { this.pad(); if (!data.length) throw new Error('EMPTY_TUPLES'); const id = this.clock.tick(1); const op = new InsVecOp(id, obj, data); const span = op.span(); if (span > 1) this.clock.tick(span - 1); this.patch.ops.push(op); return id; } /** * Set value of a "val" object. * * @returns ID of the new operation. * @todo Rename to "insVal". */ setVal(obj, val) { this.pad(); const id = this.clock.tick(1); const op = new InsValOp(id, obj, val); this.patch.ops.push(op); return id; } /** * Insert a substring into a "str" object. * * @returns ID of the new operation. */ insStr(obj, ref, data) { this.pad(); if (!data.length) throw new Error('EMPTY_STRING'); const id = this.clock.tick(1); const op = new InsStrOp(id, obj, ref, data); const span = op.span(); if (span > 1) this.clock.tick(span - 1); this.patch.ops.push(op); return id; } /** * Insert binary data into a "bin" object. * * @returns ID of the new operation. */ insBin(obj, ref, data) { this.pad(); if (!data.length) throw new Error('EMPTY_BINARY'); const id = this.clock.tick(1); const op = new InsBinOp(id, obj, ref, data); const span = op.span(); if (span > 1) this.clock.tick(span - 1); this.patch.ops.push(op); return id; } /** * Insert elements into an "arr" object. * * @returns ID of the new operation. */ insArr(arr, ref, data) { this.pad(); const id = this.clock.tick(1); const op = new InsArrOp(id, arr, ref, data); const span = op.span(); if (span > 1) this.clock.tick(span - 1); this.patch.ops.push(op); return id; } /** * Delete a span of operations. * * @param obj Object in which to delete something. * @param what List of time spans to delete. * @returns ID of the new operation. */ del(obj, what) { this.pad(); const id = this.clock.tick(1); this.patch.ops.push(new DelOp(id, obj, what)); return id; } /** * Operation that does nothing just skips IDs in the patch. * * @param span Length of the operation. * @returns ID of the new operation. * */ nop(span) { this.pad(); const id = this.clock.tick(span); this.patch.ops.push(new NopOp(id, span)); return id; } // --------------------------------------- JSON value construction operations /** * Run the necessary builder commands to create an arbitrary JSON object. */ jsonObj(obj) { const id = this.obj(); const keys = Object.keys(obj); if (keys.length) { const tuples = []; for (const k of keys) { const value = obj[k]; const valueId = value instanceof Timestamp ? value : maybeConst(value) ? this.const(value) : this.json(value); tuples.push([k, valueId]); } this.insObj(id, tuples); } return id; } /** * Run the necessary builder commands to create an arbitrary JSON array. */ jsonArr(arr) { const id = this.arr(); if (arr.length) { const values = []; for (const el of arr) values.push(this.json(el)); this.insArr(id, id, values); } return id; } /** * Run builder commands to create a JSON string. */ jsonStr(str) { const id = this.str(); if (str) this.insStr(id, id, str); return id; } /** * Run builder commands to create a binary data type. */ jsonBin(bin) { const id = this.bin(); if (bin.length) this.insBin(id, id, bin); return id; } /** * Run builder commands to create a JSON value. */ jsonVal(value) { const valId = this.val(); const id = this.const(value); this.setVal(valId, id); return valId; } /** * Run builder commands to create a tuple. */ jsonVec(vector) { const id = this.vec(); const length = vector.length; if (length) { const writes = []; for (let i = 0; i < length; i++) writes.push([i, this.constOrJson(vector[i])]); this.insVec(id, writes); } return id; } /** * Run the necessary builder commands to create any arbitrary JSON value. */ json(json) { if (json instanceof Timestamp) return json; if (json === undefined) return this.const(json); if (json instanceof Array) return this.jsonArr(json); if (isUint8Array(json)) return this.jsonBin(json); if (json instanceof VectorDelayedValue) return this.jsonVec(json.slots); if (json instanceof Konst) return this.const(json.val); if (json instanceof NodeBuilder) return json.build(this); switch (typeof json) { case 'object': return json === null ? this.jsonVal(json) : this.jsonObj(json); case 'string': return this.jsonStr(json); case 'number': case 'boolean': return this.jsonVal(json); } throw new Error('INVALID_JSON'); } /** * Given a JSON `value` creates the necessary builder commands to create * JSON CRDT Patch operations to construct the value. If the `value` is a * timestamp, it is returned as-is. If the `value` is a JSON primitive is * a number, boolean, or `null`, it is converted to a "con" data type. Otherwise, * the `value` is converted using the {@link PatchBuilder.json} method. * * @param value A JSON value for which to create JSON CRDT Patch construction operations. * @returns ID of the root constructed CRDT object. */ constOrJson(value) { if (value instanceof Timestamp) return value; return maybeConst(value) ? this.const(value) : this.json(value); } /** * Creates a "con" data type unless the value is already a timestamp, in which * case it is returned as-is. * * @param value Value to convert to a "con" data type. * @returns ID of the new "con" object. */ maybeConst(value) { return value instanceof Timestamp ? value : this.const(value); } // ------------------------------------------------------------------ Private /** * Add padding "noop" operation if clock's time has jumped. This method checks * if clock has advanced past the ID of the last operation of the patch and, * if so, adds a "noop" operation to the patch to pad the gap. */ pad() { const nextTime = this.patch.nextTime(); if (!nextTime) return; const drift = this.clock.time - nextTime; if (drift > 0) { const id = ts(this.clock.sid, nextTime); const padding = new NopOp(id, drift); this.patch.ops.push(padding); } } }