json-joy
Version:
Collection of libraries for building collaborative editing apps.
415 lines (414 loc) • 12.1 kB
JavaScript
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);
}
}
}