json-joy
Version:
Collection of libraries for building collaborative editing apps.
985 lines • 30.6 kB
JavaScript
import { printTree } from 'tree-dump/lib/printTree';
import { get } from '@jsonjoy.com/json-pointer/lib/get';
import { toPath } from '@jsonjoy.com/json-pointer/lib/util';
import { find } from './find';
import { Timestamp } from '../../../json-crdt-patch/clock';
import { ObjNode, ArrNode, BinNode, ConNode, VecNode, ValNode, StrNode, RootNode } from '../../nodes';
import { NodeEvents } from './NodeEvents';
import { FanOut } from 'thingies/lib/fanout';
import { PatchBuilder } from '../../../json-crdt-patch/PatchBuilder';
import { MergeFanOut, MicrotaskBufferFanOut } from './fanout';
import { ExtNode } from '../../extensions/ExtNode';
import * as diff from '../../../json-crdt-diff';
import { proxy$ } from './proxy';
const breakPath = (path) => {
if (!path)
return [void 0, ''];
if (typeof path === 'number')
return [void 0, path];
if (typeof path === 'string')
path = toPath(path);
switch (path.length) {
case 0:
return [void 0, ''];
case 1:
return [void 0, path[0]];
default: {
const key = path[path.length - 1];
const parent = path.slice(0, -1);
return [parent, key];
}
}
};
/**
* A generic local changes API for a JSON CRDT node.
*
* @category Local API
*/
export class NodeApi {
node;
api;
constructor(node, api) {
this.node = node;
this.api = api;
}
/** @ignore */
ev = undefined;
/**
* Event target for listening to node changes. You can subscribe to `"view"`
* events, which are triggered every time the node's view changes.
*
* ```ts
* node.events.on('view', () => {
* // do something...
* });
* ```
*/
get events() {
const et = this.ev;
return et || (this.ev = new NodeEvents(this));
}
/**
* Find a child node at the given path starting from this node.
*
* @param path Path to the child node to find.
* @returns JSON CRDT node at the given path.
*/
find(path) {
let node = this.node;
/**
* @todo Remove this .child() loop, and remove the `.child()` method from JsonNode interface.
*/
if (path === undefined) {
if (typeof node.child === 'function') {
const child = node.child();
if (!child) {
if (node instanceof RootNode)
return node;
throw new Error('NO_CHILD');
}
return child;
}
throw new Error('CANNOT_IN');
}
if (typeof path === 'string' && !!path && path[0] !== '/')
path = '/' + path;
if (typeof path === 'number')
path = [path];
while (node instanceof ValNode)
node = node.child();
return find(node, path);
}
/**
* Find a child node at the given path starting from this node and wrap it in
* a local changes API.
*
* @param path Path to the child node to find.
* @returns Local changes API for the child node at the given path.
*/
in(path) {
const node = this.find(path);
return this.api.wrap(node);
}
asVal() {
if (this.node instanceof ValNode)
return this.api.wrap(this.node);
throw new Error('NOT_VAL');
}
asStr() {
if (this.node instanceof StrNode)
return this.api.wrap(this.node);
throw new Error('NOT_STR');
}
asBin() {
if (this.node instanceof BinNode)
return this.api.wrap(this.node);
throw new Error('NOT_BIN');
}
asArr() {
if (this.node instanceof ArrNode)
return this.api.wrap(this.node);
throw new Error('NOT_ARR');
}
asVec() {
if (this.node instanceof VecNode)
return this.api.wrap(this.node);
throw new Error('NOT_VEC');
}
asObj() {
if (this.node instanceof ObjNode)
return this.api.wrap(this.node);
throw new Error('NOT_OBJ');
}
asCon() {
if (this.node instanceof ConNode)
return this.api.wrap(this.node);
throw new Error('NOT_CON');
}
asExt(ext) {
let extNode;
const node = this.node;
if (node instanceof ExtNode)
extNode = node;
if (node instanceof VecNode)
extNode = node.ext();
if (!extNode)
throw new Error('NOT_EXT');
const api = this.api.wrap(extNode);
if (!ext)
return api;
if (api instanceof ext.Api)
return api;
throw new Error('NOT_EXT');
}
val(path) {
return this.in(path).asVal();
}
str(path) {
return this.in(path).asStr();
}
bin(path) {
return this.in(path).asBin();
}
arr(path) {
return this.in(path).asArr();
}
vec(path) {
return this.in(path).asVec();
}
obj(path) {
return this.in(path).asObj();
}
con(path) {
return this.in(path).asCon();
}
view() {
return this.node.view();
}
select(path, leaf) {
try {
let node = path !== void 0 ? this.find(path) : this.node;
if (leaf)
while (node instanceof ValNode)
node = node.child();
return this.api.wrap(node);
}
catch (_e) {
return;
}
}
read(path) {
const view = this.view();
if (Array.isArray(path))
return get(view, path);
if (!path)
return view;
let path2 = path + '';
if (path && path2[0] !== '/')
path2 = '/' + path2;
return get(view, toPath(path2));
}
add(path, value) {
const [parent, key] = breakPath(path);
ADD: try {
const node = this.select(parent, true);
if (node instanceof ObjApi) {
node.set({ [key]: value });
}
else if (node instanceof ArrApi || node instanceof StrApi || node instanceof BinApi) {
const length = node.length();
let index = 0;
if (typeof key === 'number')
index = key;
else if (key === '-')
index = length;
else {
index = ~~key;
if (index + '' !== key)
break ADD;
}
if (index !== index)
break ADD;
if (index < 0)
index = 0;
if (index > length)
index = length;
if (node instanceof ArrApi)
node.ins(index, Array.isArray(value) ? value : [value]);
else if (node instanceof StrApi)
node.ins(index, value + '');
else if (node instanceof BinApi) {
if (!(value instanceof Uint8Array))
break ADD;
node.ins(index, value);
}
}
else if (node instanceof VecApi) {
node.set([[~~key, value]]);
}
else
break ADD;
return true;
}
catch { }
return false;
}
replace(path, value) {
const [parent, key] = breakPath(path);
REPLACE: try {
const node = this.select(parent, true);
if (node instanceof ObjApi) {
const keyStr = key + '';
if (!node.has(keyStr))
break REPLACE;
node.set({ [key]: value });
}
else if (node instanceof ArrApi) {
const length = node.length();
let index = 0;
if (typeof key === 'number')
index = key;
else {
index = ~~key;
if (index + '' !== key)
break REPLACE;
}
if (index !== index || index < 0 || index > length)
break REPLACE;
if (index === length)
node.ins(index, [value]);
else
node.upd(index, value);
}
else if (node instanceof VecApi)
node.set([[~~key, value]]);
else
break REPLACE;
return true;
}
catch { }
return false;
}
remove(path, length = 1) {
const [parent, key] = breakPath(path);
REMOVE: try {
const node = this.select(parent, true);
if (node instanceof ObjApi) {
const keyStr = key + '';
if (!node.has(keyStr))
break REMOVE;
node.del([keyStr]);
}
else if (node instanceof ArrApi || node instanceof StrApi || node instanceof BinApi) {
const len = node.length();
let index = 0;
if (typeof key === 'number')
index = key;
else if (key === '-')
index = length;
else {
index = ~~key;
if (index + '' !== key)
break REMOVE;
}
if (index !== index || index < 0 || index > len)
break REMOVE;
node.del(index, Math.min(length, len - index));
}
else if (node instanceof VecApi) {
node.set([[~~key, void 0]]);
}
else
break REMOVE;
return true;
}
catch { }
return false;
}
diff(value) {
return diff.diff(this, value);
}
merge(value) {
return diff.merge(this, value);
}
op(operation) {
if (!Array.isArray(operation))
return false;
const [type, path, value] = operation;
switch (type) {
case 'add':
return this.add(path, value);
case 'replace':
return this.replace(path, value);
case 'merge':
return !!this.select(path)?.merge(value);
case 'remove':
return this.remove(path, value);
}
}
get s() {
return { $: this };
}
get $() {
return proxy$((path) => {
try {
return this.api.wrap(this.find(path));
}
catch {
return;
}
}, '$');
}
toString(tab = '') {
const name = this.constructor === NodeApi ? '*' : this.node.name();
return 'api(' + name + ')' + printTree(tab, [(tab) => this.node.toString(tab)]);
}
}
/**
* Represents the local changes API for the `con` JSON CRDT node {@link ConNode}.
*
* @category Local API
*/
export class ConApi extends NodeApi {
/**
* Returns a proxy object for this node.
*/
get s() {
return { $: this };
}
}
/**
* Local changes API for the `val` JSON CRDT node {@link ValNode}.
*
* @category Local API
*/
export class ValApi extends NodeApi {
/**
* Get API instance of the inner node.
* @returns Inner node API.
*/
get() {
return this.in();
}
/**
* Sets the value of the node.
*
* @param json JSON/CBOR value or ID (logical timestamp) of the value to set.
* @returns Reference to itself.
*/
set(json) {
const { api, node } = this;
const builder = api.builder;
const val = builder.constOrJson(json);
api.builder.setVal(node.id, val);
api.apply();
}
/**
* Returns a proxy object for this node. Allows to access the value of the
* node by accessing the `.val` property.
*/
get s() {
const self = this;
const proxy = {
$: this,
get _() {
const childNode = self.node.node();
return self.api.wrap(childNode).s;
},
};
return proxy;
}
}
/**
* Local changes API for the `vec` JSON CRDT node {@link VecNode}.
*
* @category Local API
*/
export class VecApi extends NodeApi {
/**
* Get API instance of a child node.
*
* @param key Object key to get.
* @returns A specified child node API.
*/
get(key) {
return this.in(key);
}
/**
* Sets a list of elements to the given values.
*
* @param entries List of index-value pairs to set.
* @returns Reference to itself.
*/
set(entries) {
const { api, node } = this;
const { builder } = api;
builder.insVec(node.id, entries.map(([index, json]) => [index, builder.constOrJson(json)]));
api.apply();
}
push(...values) {
const length = this.length();
this.set(values.map((value, index) => [length + index, value]));
}
/**
* Get the length of the vector without materializing it to a view.
*
* @returns Length of the vector.
*/
length() {
return this.node.elements.length;
}
/**
* Returns a proxy object for this node. Allows to access vector elements by
* index.
*/
get s() {
const proxy = new Proxy({}, {
get: (target, prop, receiver) => {
if (prop === '$')
return this;
if (prop === 'toExt')
return () => this.asExt();
const index = Number(prop);
if (Number.isNaN(index))
throw new Error('INVALID_INDEX');
const child = this.node.get(index);
if (!child)
throw new Error('OUT_OF_BOUNDS');
return this.api.wrap(child).s;
},
});
return proxy;
}
}
/**
* Local changes API for the `obj` JSON CRDT node {@link ObjNode}.
*
* @category Local API
*/
export class ObjApi extends NodeApi {
/**
* Get API instance of a child node.
*
* @param key Object key to get.
* @returns A specified child node API.
*/
get(key) {
return this.in(key);
}
/**
* Sets a list of keys to the given values.
*
* @param entries List of key-value pairs to set.
* @returns Reference to itself.
*/
set(entries) {
const { api, node } = this;
const { builder } = api;
builder.insObj(node.id, Object.entries(entries).map(([key, json]) => [key, builder.constOrJson(json)]));
api.apply();
}
/**
* Deletes a list of keys from the object.
*
* @param keys List of keys to delete.
* @returns Reference to itself.
*/
del(keys) {
const { api, node } = this;
const { builder } = api;
api.builder.insObj(node.id, keys.map((key) => [key, builder.con(undefined)]));
api.apply();
}
/**
* Checks if a key exists in the object.
*
* @param key Key to check.
* @returns True if the key exists, false otherwise.
*/
has(key) {
return this.node.keys.has(key);
}
/**
* Returns a proxy object for this node. Allows to access object properties
* by key.
*/
get s() {
const proxy = new Proxy({}, {
get: (target, prop, receiver) => {
if (prop === '$')
return this;
const key = String(prop);
const child = this.node.get(key);
if (!child)
throw new Error('NO_SUCH_KEY');
return this.api.wrap(child).s;
},
});
return proxy;
}
}
/**
* Local changes API for the `str` JSON CRDT node {@link StrNode}. This API
* allows to insert and delete bytes in the UTF-16 string by referencing its
* local character positions.
*
* @category Local API
*/
export class StrApi extends NodeApi {
/**
* Inserts text at a given position.
*
* @param index Position at which to insert text.
* @param text Text to insert.
* @returns Reference to itself.
*/
ins(index, text) {
const { api, node } = this;
api.onBeforeLocalChange.emit(api.next);
const builder = api.builder;
builder.pad();
const nextTime = api.builder.nextTime();
const id = new Timestamp(builder.clock.sid, nextTime);
const after = node.insAt(index, id, text);
if (!after)
throw new Error('OUT_OF_BOUNDS');
builder.insStr(node.id, after, text);
api.advance();
}
/**
* Deletes a range of text at a given position.
*
* @param index Position at which to delete text.
* @param length Number of UTF-16 code units to delete.
* @returns Reference to itself.
*/
del(index, length) {
const { api, node } = this;
api.onBeforeLocalChange.emit(api.next);
const builder = api.builder;
builder.pad();
const spans = node.findInterval(index, length);
if (!spans)
throw new Error('OUT_OF_BOUNDS');
node.delete(spans);
builder.del(node.id, spans);
api.advance();
}
/**
* Given a character index in local coordinates, find the ID of the character
* in the global coordinates.
*
* @param index Index of the character or `-1` for before the first character.
* @returns ID of the character after which the given position is located.
*/
findId(index) {
const node = this.node;
const length = node.length();
const max = length - 1;
if (index > max)
index = max;
if (index < 0)
return node.id;
const id = node.find(index);
return id || node.id;
}
/**
* Given a position in global coordinates, find the position in local
* coordinates.
*
* @param id ID of the character.
* @returns Index of the character in local coordinates. Returns -1 if the
* the position refers to the beginning of the string.
*/
findPos(id) {
const node = this.node;
const nodeId = node.id;
if (nodeId.sid === id.sid && nodeId.time === id.time)
return -1;
const chunk = node.findById(id);
if (!chunk)
return -1;
const pos = node.pos(chunk);
return pos + (chunk.del ? 0 : id.time - chunk.id.time);
}
/**
* Get the length of the string without materializing it to a view.
*
* @returns Length of the string.
*/
length() {
return this.node.length();
}
/**
* Returns a proxy object for this node.
*/
get s() {
return { $: this };
}
}
/**
* Local changes API for the `bin` JSON CRDT node {@link BinNode}. This API
* allows to insert and delete bytes in the binary string by referencing their
* local index.
*
* @category Local API
*/
export class BinApi extends NodeApi {
/**
* Inserts octets at a given position.
*
* @param index Position at which to insert octets.
* @param data Octets to insert.
* @returns Reference to itself.
*/
ins(index, data) {
const { api, node } = this;
const after = !index ? node.id : node.find(index - 1);
if (!after)
throw new Error('OUT_OF_BOUNDS');
api.builder.insBin(node.id, after, data);
api.apply();
}
/**
* Deletes a range of octets at a given position.
*
* @param index Position at which to delete octets.
* @param length Number of octets to delete.
* @returns Reference to itself.
*/
del(index, length) {
const { api, node } = this;
const spans = node.findInterval(index, length);
if (!spans)
throw new Error('OUT_OF_BOUNDS');
api.builder.del(node.id, spans);
api.apply();
}
/**
* Get the length of the binary blob without materializing it to a view.
*
* @returns Length of the binary blob.
*/
length() {
return this.node.length();
}
/**
* Returns a proxy object for this node.
*/
get s() {
return { $: this };
}
}
/**
* Local changes API for the `arr` JSON CRDT node {@link ArrNode}. This API
* allows to insert and delete elements in the array by referencing their local
* index.
*
* @category Local API
*/
export class ArrApi extends NodeApi {
/**
* Get API instance of a child node.
*
* @param index Index of the element to get.
* @returns Child node API for the element at the given index.
*/
get(index) {
return this.in(index);
}
/**
* Inserts elements at a given position.
*
* @param index Position at which to insert elements.
* @param values Values or schema of the elements to insert.
*/
ins(index, values) {
const { api, node } = this;
const { builder } = api;
const after = !index ? node.id : node.find(index - 1);
if (!after)
throw new Error('OUT_OF_BOUNDS');
const valueIds = [];
for (let i = 0; i < values.length; i++)
valueIds.push(builder.json(values[i]));
builder.insArr(node.id, after, valueIds);
api.apply();
}
/**
* Inserts elements at the end of the array.
*
* @param values Values or schema of the elements to insert at the end of the array.
*/
push(...values) {
const length = this.length();
this.ins(length, values);
}
/**
* Updates (overwrites) an element at a given position.
*
* @param index Position at which to update the element.
* @param value Value or schema of the element to replace with.
*/
upd(index, value) {
const { api, node } = this;
const ref = node.getId(index);
if (!ref)
throw new Error('OUT_OF_BOUNDS');
const { builder } = api;
builder.updArr(node.id, ref, builder.constOrJson(value));
api.apply();
}
/**
* Deletes a range of elements at a given position.
*
* @param index Position at which to delete elements.
* @param length Number of elements to delete.
* @returns Reference to itself.
*/
del(index, length) {
const { api, node } = this;
const spans = node.findInterval(index, length);
if (!spans)
throw new Error('OUT_OF_BOUNDS');
api.builder.del(node.id, spans);
api.apply();
}
/**
* Get the length of the array without materializing it to a view.
*
* @returns Length of the array.
*/
length() {
return this.node.length();
}
/**
* Returns a proxy object that allows to access array elements by index.
*
* @returns Proxy object that allows to access array elements by index.
*/
get s() {
const proxy = new Proxy({}, {
get: (target, prop, receiver) => {
if (prop === '$')
return this;
const index = Number(prop);
if (Number.isNaN(index))
throw new Error('INVALID_INDEX');
const child = this.node.getNode(index);
if (!child)
throw new Error('OUT_OF_BOUNDS');
return this.api.wrap(child).s;
},
});
return proxy;
}
}
/**
* Local changes API for a JSON CRDT model. This class is the main entry point
* for executing local user actions on a JSON CRDT document.
*
* @category Local API
*/
export class ModelApi extends ValApi {
model;
/**
* Patch builder for the local changes.
*/
builder;
/**
* Index of the next operation in builder's patch to be committed locally.
*
* @ignore
*/
next = 0;
/** Emitted before the model is reset, using the `.reset()` method. */
onBeforeReset = new FanOut();
/** Emitted after the model is reset, using the `.reset()` method. */
onReset = new FanOut();
/** Emitted before a patch is applied using `model.applyPatch()`. */
onBeforePatch = new FanOut();
/** Emitted after a patch is applied using `model.applyPatch()`. */
onPatch = new FanOut();
/** Emitted before local changes through `model.api` are applied. */
onBeforeLocalChange = new FanOut();
/** Emitted after local changes through `model.api` are applied. */
onLocalChange = new FanOut();
/**
* Emitted after local changes through `model.api` are applied. Same as
* `.onLocalChange`, but this event buffered withing a microtask.
*/
onLocalChanges = new MicrotaskBufferFanOut(this.onLocalChange);
/** Emitted before a transaction is started. */
onBeforeTransaction = new FanOut();
/** Emitted after transaction completes. */
onTransaction = new FanOut();
/** Emitted when the model changes. Combines `onReset`, `onPatch` and `onLocalChange`. */
onChange = new MergeFanOut([
this.onReset,
this.onPatch,
this.onLocalChange,
]);
/** Emitted when the model changes. Same as `.onChange`, but this event is emitted once per microtask. */
onChanges = new MicrotaskBufferFanOut(this.onChange);
/** Emitted when the `model.api` builder change buffer is flushed. */
onFlush = new FanOut();
/**
* @param model Model instance on which the API operates.
*/
constructor(model) {
super(model.root, void 0);
this.model = model;
this.api = this;
this.builder = new PatchBuilder(model.clock);
model.onbeforereset = () => this.onBeforeReset.emit();
model.onreset = () => this.onReset.emit();
model.onbeforepatch = (patch) => this.onBeforePatch.emit(patch);
model.onpatch = (patch) => this.onPatch.emit(patch);
}
wrap(node) {
if (node instanceof ValNode)
return node.api || (node.api = new ValApi(node, this));
else if (node instanceof StrNode)
return node.api || (node.api = new StrApi(node, this));
else if (node instanceof BinNode)
return node.api || (node.api = new BinApi(node, this));
else if (node instanceof ArrNode)
return node.api || (node.api = new ArrApi(node, this));
else if (node instanceof ObjNode)
return node.api || (node.api = new ObjApi(node, this));
else if (node instanceof ConNode)
return node.api || (node.api = new ConApi(node, this));
else if (node instanceof VecNode)
return node.api || (node.api = new VecApi(node, this));
else if (node instanceof ExtNode) {
if (node.api)
return node.api;
const extension = this.model.ext.get(node.extId);
return (node.api = new extension.Api(node, this));
}
else
throw new Error('UNKNOWN_NODE');
}
/**
* Given a JSON/CBOR value, constructs CRDT nodes recursively out of it and
* sets the root node of the model to the constructed nodes.
*
* @param json JSON/CBOR value to set as the view of the model.
* @returns Reference to itself.
*
* @deprecated Use `.set()` instead.
*/
root(json) {
return this.set(json);
}
set(json) {
super.set(json);
return this;
}
/**
* Apply locally any operations from the `.builder`, which haven't been
* applied yet.
*/
apply() {
const ops = this.builder.patch.ops;
const length = ops.length;
const model = this.model;
const from = this.next;
this.onBeforeLocalChange.emit(from);
for (let i = this.next; i < length; i++)
model.applyOperation(ops[i]);
this.next = length;
model.tick++;
this.onLocalChange.emit(from);
}
/**
* Advance patch pointer to the end without applying the operations. With the
* idea that they have already been applied locally.
*
* You need to manually call `this.onBeforeLocalChange.emit(this.next)` before
* calling this method.
*
* @ignore
*/
advance() {
const from = this.next;
this.next = this.builder.patch.ops.length;
this.model.tick++;
this.onLocalChange.emit(from);
}
inTx = false;
transaction(callback) {
if (this.inTx)
callback();
else {
this.inTx = true;
try {
this.onBeforeTransaction.emit();
callback();
this.onTransaction.emit();
}
finally {
this.inTx = false;
}
}
}
/**
* Flushes the builder and returns a patch.
*
* @returns A JSON CRDT patch.
* @todo Make this return undefined if there are no operations in the builder.
*/
flush() {
const patch = this.builder.flush();
this.next = 0;
if (patch.ops.length)
this.onFlush.emit(patch);
return patch;
}
stopAutoFlush = undefined;
/**
* Begins to automatically flush buffered operations into patches, grouping
* operations by microtasks or by transactions. To capture the patch, listen
* to the `.onFlush` event.
*
* @returns Callback to stop auto flushing.
*/
autoFlush(drainNow = false) {
const drain = () => this.builder.patch.ops.length && this.flush();
const onLocalChangesUnsubscribe = this.onLocalChanges.listen(drain);
const onBeforeTransactionUnsubscribe = this.onBeforeTransaction.listen(drain);
const onTransactionUnsubscribe = this.onTransaction.listen(drain);
if (drainNow)
drain();
return (this.stopAutoFlush = () => {
this.stopAutoFlush = undefined;
onLocalChangesUnsubscribe();
onBeforeTransactionUnsubscribe();
onTransactionUnsubscribe();
});
}
// ---------------------------------------------------------------- SyncStore
subscribe = (callback) => this.onChanges.listen(() => callback());
getSnapshot = () => this.view();
}
//# sourceMappingURL=nodes.js.map