UNPKG

json-joy

Version:

Collection of libraries for building collaborative editing apps.

964 lines (963 loc) 30.1 kB
import { compare, tick, tss, printTs, containsId, Timestamp, } from '../../../json-crdt-patch/clock'; import { isUint8Array } from '@jsonjoy.com/util/lib/buffers/isUint8Array'; import { rSplay, lSplay, llSplay, rrSplay, lrSplay, rlSplay } from 'sonic-forest/lib/splay/util'; import { splay2 } from 'sonic-forest/lib/splay/util2'; import { insert2, remove2 } from 'sonic-forest/lib/util2'; import { ORIGIN } from '../../../json-crdt-patch/constants'; import { printTree } from 'tree-dump/lib/printTree'; import { printBinary } from 'tree-dump/lib/printBinary'; import { printOctets } from '@jsonjoy.com/util/lib/buffers/printOctets'; const compareById = (c1, c2) => { const ts1 = c1.id; const ts2 = c2.id; return ts1.sid - ts2.sid || ts1.time - ts2.time; }; const updateLenOne = (chunk) => { const l = chunk.l; const r = chunk.r; chunk.len = (chunk.del ? 0 : chunk.span) + (l ? l.len : 0) + (r ? r.len : 0); }; const updateLenOneLive = (chunk) => { const l = chunk.l; const r = chunk.r; chunk.len = chunk.span + (l ? l.len : 0) + (r ? r.len : 0); }; const dLen = (chunk, delta) => { while (chunk) { chunk.len += delta; chunk = chunk.p; } }; const next = (curr) => { const r = curr.r; if (r) { curr = r; let tmp; while ((tmp = curr.l)) curr = tmp; return curr; } let p = curr.p; while (p && p.r === curr) { curr = p; p = p.p; } return p; }; const prev = (curr) => { const l = curr.l; if (l) { curr = l; let tmp; while ((tmp = curr.r)) curr = tmp; return curr; } let p = curr.p; while (p && p.l === curr) { curr = p; p = p.p; } return p; }; /** * @category CRDT Node */ export class AbstractRga { id; root = undefined; ids = undefined; count = 0; constructor(id) { this.id = id; } // --------------------------------------------------------------- Public API ins(after, id, content) { const rootId = this.id; const afterTime = after.time; const afterSid = after.sid; // TODO: perf: sid equality check is redundant? As it is implied by time equality. const isRootInsert = rootId.time === afterTime && rootId.sid === afterSid; if (isRootInsert) { this.insAfterRoot(after, id, content); return; } let curr = this.ids; let chunk = curr; while (curr) { const currId = curr.id; const currIdSid = currId.sid; if (currIdSid > afterSid) { curr = curr.l2; } else if (currIdSid < afterSid) { chunk = curr; curr = curr.r2; } else { const currIdTime = currId.time; if (currIdTime > afterTime) { curr = curr.l2; } else if (currIdTime < afterTime) { chunk = curr; curr = curr.r2; } else { chunk = curr; break; } } } if (!chunk) return; const atId = chunk.id; const atIdTime = atId.time; const atIdSid = atId.sid; const atSpan = chunk.span; if (atIdSid !== afterSid) return; const offset = afterTime - atIdTime; if (offset >= atSpan) return; const offsetInInsertAtChunk = afterTime - atIdTime; this.insAfterChunk(after, chunk, offsetInInsertAtChunk, id, content); } insAt(position, id, content) { if (!position) { const rootId = this.id; this.insAfterRoot(rootId, id, content); return rootId; } const found = this.findChunk(position - 1); if (!found) return undefined; const [at, offset] = found; const atId = at.id; const after = offset === 0 ? atId : new Timestamp(atId.sid, atId.time + offset); this.insAfterChunk(after, at, offset, id, content); return after; } insAfterRoot(after, id, content) { const chunk = this.createChunk(id, content); const first = this.first(); if (!first) this.setRoot(chunk); else if (compare(first.id, id) < 0) this.insertBefore(chunk, first); else { if (containsId(first.id, first.span, id)) return; this.insertAfterRef(chunk, after, first); } } insAfterChunk(after, chunk, chunkOffset, id, content) { const atId = chunk.id; const atIdTime = atId.time; const atIdSid = atId.sid; const atSpan = chunk.span; const newChunk = this.createChunk(id, content); const needsSplit = chunkOffset + 1 < atSpan; if (needsSplit) { const idSid = id.sid; const idTime = id.time; if (atIdSid === idSid && atIdTime <= idTime && atIdTime + atSpan - 1 >= idTime) return; if (idTime > after.time + 1 || idSid > after.sid) { this.insertInside(newChunk, chunk, chunkOffset + 1); this.splay(newChunk); return; } } this.insertAfterRef(newChunk, after, chunk); this.splay(newChunk); } delete(spans) { const length = spans.length; for (let i = 0; i < length; i++) this.deleteSpan(spans[i]); this.onChange(); } deleteSpan(span) { const len = span.span; const t1 = span.time; const t2 = t1 + len - 1; const start = this.findById(span); if (!start) return; let chunk = start; let last = chunk; while (chunk) { last = chunk; const id = chunk.id; const chunkSpan = chunk.span; const c1 = id.time; const c2 = c1 + chunkSpan - 1; if (chunk.del) { if (c2 >= t2) break; chunk = chunk.s; continue; } const deleteStartsFromLeft = t1 <= c1; const deleteStartsInTheMiddle = t1 <= c2; if (deleteStartsFromLeft) { const deleteFullyContainsChunk = t2 >= c2; if (deleteFullyContainsChunk) { chunk.delete(); dLen(chunk, -chunk.span); if (t2 <= c2) break; } else { const range = t2 - c1 + 1; const newChunk = this.split(chunk, range); chunk.delete(); updateLenOne(newChunk); dLen(chunk, -chunk.span); break; } } else if (deleteStartsInTheMiddle) { const deleteContainsRightSide = t2 >= c2; if (deleteContainsRightSide) { const offset = t1 - c1; const newChunk = this.split(chunk, offset); newChunk.delete(); newChunk.len = newChunk.r ? newChunk.r.len : 0; dLen(chunk, -newChunk.span); if (t2 <= c2) break; } else { const right = this.split(chunk, t2 - c1 + 1); const mid = this.split(chunk, t1 - c1); mid.delete(); updateLenOne(right); updateLenOne(mid); dLen(chunk, -mid.span); break; } } chunk = chunk.s; } if (last) this.mergeTombstones2(start, last); } find(position) { let curr = this.root; while (curr) { const l = curr.l; const leftLength = l ? l.len : 0; let span; if (position < leftLength) curr = l; else if (curr.del) { position -= leftLength; curr = curr.r; } else if (position < leftLength + (span = curr.span)) { const ticks = position - leftLength; const id = curr.id; return !ticks ? id : new Timestamp(id.sid, id.time + ticks); } else { position -= leftLength + span; curr = curr.r; } } return; } findChunk(position) { let curr = this.root; while (curr) { const l = curr.l; const leftLength = l ? l.len : 0; let span; if (position < leftLength) curr = l; else if (curr.del) { position -= leftLength; curr = curr.r; } else if (position < leftLength + (span = curr.span)) { return [curr, position - leftLength]; } else { position -= leftLength + span; curr = curr.r; } } return; } findInterval(position, length) { const ranges = []; if (!length) return ranges; let curr = this.root; let offset = 0; while (curr) { const leftLength = curr.l ? curr.l.len : 0; if (position < leftLength) curr = curr.l; else if (curr.del) { position -= leftLength; curr = curr.r; } else if (position < leftLength + curr.span) { offset = position - leftLength; break; } else { position -= leftLength + curr.span; curr = curr.r; } } if (!curr) return ranges; if (curr.span >= length + offset) { const id = curr.id; ranges.push(tss(id.sid, id.time + offset, length)); return ranges; } const len = curr.span - offset; const id = curr.id; ranges.push(tss(id.sid, id.time + offset, len)); length -= len; curr = next(curr); if (!curr) return ranges; do { if (curr.del) continue; const id = curr.id; const span = curr.span; if (length <= span) { ranges.push(tss(id.sid, id.time, length)); return ranges; } ranges.push(tss(id.sid, id.time, span)); length -= span; } while ((curr = next(curr)) && length > 0); return ranges; } /** Rename to .rangeX() method? */ findInterval2(from, to) { const ranges = []; this.range0(undefined, from, to, (chunk, off, len) => { const id = chunk.id; ranges.push(tss(id.sid, id.time + off, len)); }); return ranges; } /** * @note All ".rangeX()" method are not performance optimized. For hot paths * it is better to hand craft the loop. * * @param startChunk Chunk from which to start the range. If undefined, the * chunk containing `from` will be used. This is an optimization * to avoid a lookup. * @param from ID of the first element in the range. * @param to ID of the last element in the range. * @param callback Function to call for each chunk slice in the range. If it * returns truthy value, the iteration will stop. * @returns Reference to the last chunk in the range. */ range0(startChunk, from, to, callback) { let chunk = startChunk ? startChunk : this.findById(from); if (startChunk) while (chunk && !containsId(chunk.id, chunk.span, from)) chunk = next(chunk); if (!chunk) return; if (!chunk.del) { const off = from.time - chunk.id.time; const toContainedInChunk = containsId(chunk.id, chunk.span, to); if (toContainedInChunk) { const len = to.time - from.time + 1; callback(chunk, off, len); return chunk; } const len = chunk.span - off; if (callback(chunk, off, len)) return chunk; } else { if (containsId(chunk.id, chunk.span, to)) return; } chunk = next(chunk); while (chunk) { const toContainedInChunk = containsId(chunk.id, chunk.span, to); // TODO: fast path for chunk.del if (toContainedInChunk) { if (!chunk.del) if (callback(chunk, 0, to.time - chunk.id.time + 1)) return chunk; return chunk; } if (!chunk.del) if (callback(chunk, 0, chunk.span)) return chunk; chunk = next(chunk); } return chunk; } // ---------------------------------------------------------------- Retrieval first() { let curr = this.root; while (curr) { const l = curr.l; if (l) curr = l; else return curr; } return curr; } last() { let curr = this.root; while (curr) { const r = curr.r; if (r) curr = r; else return curr; } return curr; } lastId() { const chunk = this.last(); if (!chunk) return undefined; const id = chunk.id; const span = chunk.span; return span === 1 ? id : new Timestamp(id.sid, id.time + span - 1); } /** @todo Maybe use implementation from tree utils, if does not impact performance. */ /** @todo Or better remove this method completely, as it does not require "this". */ next(curr) { return next(curr); } /** @todo Maybe use implementation from tree utils, if does not impact performance. */ /** @todo Or better remove this method completely, as it does not require "this". */ prev(curr) { return prev(curr); } /** Content length. */ length() { const root = this.root; return root ? root.len : 0; } /** Number of chunks. */ size() { return this.count; } /** Returns the position of the first element in the chunk. */ pos(chunk) { const p = chunk.p; const l = chunk.l; if (!p) return l ? l.len : 0; const parentPos = this.pos(p); const isRightChild = p.r === chunk; if (isRightChild) return parentPos + (p.del ? 0 : p.span) + (l ? l.len : 0); const r = chunk.r; return parentPos - (chunk.del ? 0 : chunk.span) - (r ? r.len : 0); } // --------------------------------------------------------------- Insertions setRoot(chunk) { this.root = chunk; this.insertId(chunk); this.onChange(); } insertBefore(chunk, before) { const l = before.l; before.l = chunk; chunk.l = l; chunk.p = before; let lLen = 0; if (l) { l.p = chunk; lLen = l.len; } chunk.len = chunk.span + lLen; dLen(before, chunk.span); this.insertId(chunk); this.onChange(); } insertAfter(chunk, after) { const r = after.r; after.r = chunk; chunk.r = r; chunk.p = after; let rLen = 0; if (r) { r.p = chunk; rLen = r.len; } chunk.len = chunk.span + rLen; dLen(after, chunk.span); this.insertId(chunk); this.onChange(); } insertAfterRef(chunk, ref, left) { const id = chunk.id; const sid = id.sid; const time = id.time; let isSplit = false; for (;;) { const leftId = left.id; const leftNextTick = leftId.time + left.span; if (!left.s) { isSplit = leftId.sid === sid && leftNextTick === time && leftNextTick - 1 === ref.time; if (isSplit) left.s = chunk; } const right = next(left); if (!right) break; const rightId = right.id; const rightIdTime = rightId.time; const rightIdSid = rightId.sid; if (rightIdTime < time) break; if (rightIdTime === time) { if (rightIdSid === sid) return; if (rightIdSid < sid) break; } left = right; } if (isSplit && !left.del) { this.mergeContent(left, chunk.data); left.s = undefined; } else this.insertAfter(chunk, left); } mergeContent(chunk, content) { const span1 = chunk.span; chunk.merge(content); dLen(chunk, chunk.span - span1); this.onChange(); return; } insertInside(chunk, at, offset) { const p = at.p; const l = at.l; const r = at.r; const s = at.s; const len = at.len; const at2 = at.split(offset); at.s = at2; at2.s = s; at.l = at.r = at2.l = at2.r = undefined; at2.l = undefined; chunk.p = p; if (!l) { chunk.l = at; at.p = chunk; } else { chunk.l = l; l.p = chunk; const a = l.r; l.r = at; at.p = l; at.l = a; if (a) a.p = at; } if (!r) { chunk.r = at2; at2.p = chunk; } else { chunk.r = r; r.p = chunk; const b = r.l; r.l = at2; at2.p = r; at2.r = b; if (b) b.p = at2; } if (!p) this.root = chunk; else if (p.l === at) p.l = chunk; else p.r = chunk; updateLenOne(at); updateLenOne(at2); if (l) l.len = (l.l ? l.l.len : 0) + at.len + (l.del ? 0 : l.span); if (r) r.len = (r.r ? r.r.len : 0) + at2.len + (r.del ? 0 : r.span); chunk.len = len + chunk.span; const span = chunk.span; let curr = chunk.p; while (curr) { curr.len += span; curr = curr.p; } // TODO: perf: could insert these two ids in one go this.insertId(at2); this.insertIdFast(chunk); this.onChange(); } split(chunk, ticks) { const s = chunk.s; const newChunk = chunk.split(ticks); const r = chunk.r; chunk.s = newChunk; newChunk.r = r; newChunk.s = s; chunk.r = newChunk; newChunk.p = chunk; this.insertId(newChunk); if (r) r.p = newChunk; return newChunk; } mergeTombstones(ch1, ch2) { if (!ch1.del || !ch2.del) return false; const id1 = ch1.id; const id2 = ch2.id; if (id1.sid !== id2.sid) return false; if (id1.time + ch1.span !== id2.time) return false; ch1.s = ch2.s; ch1.span += ch2.span; this.deleteChunk(ch2); return true; } mergeTombstones2(start, end) { let curr = start; while (curr) { const nextCurr = next(curr); if (!nextCurr) break; const merged = this.mergeTombstones(curr, nextCurr); if (!merged) { if (nextCurr === end) { if (nextCurr) { const n = next(nextCurr); if (n) this.mergeTombstones(nextCurr, n); } break; } curr = curr.s; } } const left = prev(start); if (left) this.mergeTombstones(left, start); } removeTombstones() { let curr = this.first(); const list = []; while (curr) { if (curr.del) list.push(curr); curr = next(curr); } for (let i = 0; i < list.length; i++) this.deleteChunk(list[i]); } deleteChunk(chunk) { this.deleteId(chunk); const p = chunk.p; const l = chunk.l; const r = chunk.r; chunk.id = ORIGIN; // mark chunk as disposed // TODO: perf: maybe set .p, .l, .r to undefined to help GC? if (!l && !r) { if (!p) this.root = undefined; else { if (p.l === chunk) p.l = undefined; else p.r = undefined; } } else if (l && r) { let mostRightChildFromLeft = l; while (mostRightChildFromLeft.r) mostRightChildFromLeft = mostRightChildFromLeft.r; mostRightChildFromLeft.r = r; r.p = mostRightChildFromLeft; const rLen = r.len; let curr; curr = mostRightChildFromLeft; if (!p) { this.root = l; l.p = undefined; } else { if (p.l === chunk) p.l = l; else p.r = l; l.p = p; } while (curr && curr !== p) { curr.len += rLen; curr = curr.p; } } else { const child = (l || r); child.p = p; if (!p) this.root = child; else if (p.l === chunk) p.l = child; else p.r = child; } } insertId(chunk) { this.ids = insert2(this.ids, chunk, compareById); this.count++; this.ids = splay2(this.ids, chunk); } insertIdFast(chunk) { this.ids = insert2(this.ids, chunk, compareById); this.count++; } deleteId(chunk) { this.ids = remove2(this.ids, chunk); this.count--; } findById(after) { const afterSid = after.sid; const afterTime = after.time; let curr = this.ids; let chunk = curr; while (curr) { const currId = curr.id; const currIdSid = currId.sid; if (currIdSid > afterSid) { curr = curr.l2; } else if (currIdSid < afterSid) { chunk = curr; curr = curr.r2; } else { const currIdTime = currId.time; if (currIdTime > afterTime) { curr = curr.l2; } else if (currIdTime < afterTime) { chunk = curr; curr = curr.r2; } else { chunk = curr; break; } } } if (!chunk) return; const atId = chunk.id; const atIdTime = atId.time; const atIdSid = atId.sid; const atSpan = chunk.span; if (atIdSid !== afterSid) return; if (afterTime < atIdTime) return; const offset = afterTime - atIdTime; if (offset >= atSpan) return; return chunk; } /** * @param id ID of character to start the search from. * @returns Previous ID in the RGA sequence. */ prevId(id) { let chunk = this.findById(id); if (!chunk) return; const time = id.time; if (chunk.id.time < time) return new Timestamp(id.sid, time - 1); chunk = prev(chunk); if (!chunk) return; const prevId = chunk.id; const span = chunk.span; return span > 1 ? new Timestamp(prevId.sid, prevId.time + chunk.span - 1) : prevId; } spanView(span) { const view = []; let remaining = span.span; const time = span.time; let chunk = this.findById(span); if (!chunk) return view; if (!chunk.del) { if (chunk.span >= remaining + time - chunk.id.time) { const offset = time - chunk.id.time; const end = offset + remaining; const viewChunk = chunk.view().slice(offset, end); view.push(viewChunk); return view; } else { const offset = time - chunk.id.time; const viewChunk = chunk.view().slice(offset, span.span); remaining -= chunk.span - offset; view.push(viewChunk); } } while ((chunk = chunk.s)) { const chunkSpan = chunk.span; if (!chunk.del) { if (chunkSpan > remaining) { const viewChunk = chunk.view().slice(0, remaining); view.push(viewChunk); break; } view.push(chunk.data); } remaining -= chunkSpan; if (remaining <= 0) break; } return view; } // ---------------------------------------------------------- Splay balancing splay(chunk) { const p = chunk.p; if (!p) return; const pp = p.p; const l2 = p.l === chunk; if (!pp) { if (l2) rSplay(chunk, p); else lSplay(chunk, p); this.root = chunk; updateLenOne(p); updateLenOneLive(chunk); return; } const l1 = pp.l === p; if (l1) { if (l2) { this.root = llSplay(this.root, chunk, p, pp); } else { this.root = lrSplay(this.root, chunk, p, pp); } } else { if (l2) { this.root = rlSplay(this.root, chunk, p, pp); } else { this.root = rrSplay(this.root, chunk, p, pp); } } updateLenOne(pp); updateLenOne(p); updateLenOneLive(chunk); this.splay(chunk); } // ---------------------------------------------------------- Export / Import iterator() { let curr = this.first(); return () => { const res = curr; if (curr) curr = next(curr); return res; }; } ingest(size, next) { if (size < 1) return; const splitLeftChunks = new Map(); this.root = this._ingest(size, () => { const chunk = next(); const id = chunk.id; const key = id.sid + '.' + id.time; const split = splitLeftChunks.get(key); if (split) { split.s = chunk; splitLeftChunks.delete(key); } const nextStampAfterSpan = tick(id, chunk.span); splitLeftChunks.set(nextStampAfterSpan.sid + '.' + nextStampAfterSpan.time, chunk); return chunk; }); } _ingest(size, next) { const leftSize = size >> 1; const rightSize = size - leftSize - 1; const c1 = leftSize > 0 ? this._ingest(leftSize, next) : undefined; const c2 = next(); if (c1) { c2.l = c1; c1.p = c2; } const c3 = rightSize > 0 ? this._ingest(rightSize, next) : undefined; if (c3) { c2.r = c3; c3.p = c2; } updateLenOne(c2); // TODO: perf: splay only nodes with hight clock values and which are not tombstones? this.insertId(c2); return c2; } // ---------------------------------------------------------------- Printable toStringName() { return 'AbstractRga'; } toString(tab = '') { const view = this.view(); let value = ''; if (isUint8Array(view)) value += ` { ${printOctets(view) || '∅'} }`; else if (typeof view === 'string') value += `{ ${view.length > 32 ? JSON.stringify(view.substring(0, 32)) + ' …' : JSON.stringify(view)} }`; const header = `${this.toStringName()} ${printTs(this.id)} ${value}`; return header + printTree(tab, [(tab) => (this.root ? this.printChunk(tab, this.root) : '∅')]); } printChunk(tab, chunk) { return (this.formatChunk(chunk) + printBinary(tab, [ chunk.l ? (tab) => this.printChunk(tab, chunk.l) : null, chunk.r ? (tab) => this.printChunk(tab, chunk.r) : null, ])); } formatChunk(chunk) { const id = printTs(chunk.id); let str = `chunk ${id}:${chunk.span} .${chunk.len}.`; if (chunk.del) str += ` [${chunk.span}]`; else { if (isUint8Array(chunk.data)) str += ` { ${printOctets(chunk.data) || '∅'} }`; else if (typeof chunk.data === 'string') { const data = chunk.data.length > 32 ? JSON.stringify(chunk.data.substring(0, 32)) + ' …' : JSON.stringify(chunk.data); str += ` { ${data} }`; } } return str; } }