UNPKG

@terrencecrowley/ot-js

Version:
1,197 lines (1,189 loc) 133 kB
/******/ (function(modules) { // webpackBootstrap /******/ // The module cache /******/ var installedModules = {}; /******/ /******/ // The require function /******/ function __webpack_require__(moduleId) { /******/ /******/ // Check if module is in cache /******/ if(installedModules[moduleId]) { /******/ return installedModules[moduleId].exports; /******/ } /******/ // Create a new module (and put it into the cache) /******/ var module = installedModules[moduleId] = { /******/ i: moduleId, /******/ l: false, /******/ exports: {} /******/ }; /******/ /******/ // Execute the module function /******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); /******/ /******/ // Flag the module as loaded /******/ module.l = true; /******/ /******/ // Return the exports of the module /******/ return module.exports; /******/ } /******/ /******/ /******/ // expose the modules object (__webpack_modules__) /******/ __webpack_require__.m = modules; /******/ /******/ // expose the module cache /******/ __webpack_require__.c = installedModules; /******/ /******/ // define getter function for harmony exports /******/ __webpack_require__.d = function(exports, name, getter) { /******/ if(!__webpack_require__.o(exports, name)) { /******/ Object.defineProperty(exports, name, { enumerable: true, get: getter }); /******/ } /******/ }; /******/ /******/ // define __esModule on exports /******/ __webpack_require__.r = function(exports) { /******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { /******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); /******/ } /******/ Object.defineProperty(exports, '__esModule', { value: true }); /******/ }; /******/ /******/ // create a fake namespace object /******/ // mode & 1: value is a module id, require it /******/ // mode & 2: merge all properties of value into the ns /******/ // mode & 4: return value when already ns object /******/ // mode & 8|1: behave like require /******/ __webpack_require__.t = function(value, mode) { /******/ if(mode & 1) value = __webpack_require__(value); /******/ if(mode & 8) return value; /******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value; /******/ var ns = Object.create(null); /******/ __webpack_require__.r(ns); /******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value }); /******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key)); /******/ return ns; /******/ }; /******/ /******/ // getDefaultExport function for compatibility with non-harmony modules /******/ __webpack_require__.n = function(module) { /******/ var getter = module && module.__esModule ? /******/ function getDefault() { return module['default']; } : /******/ function getModuleExports() { return module; }; /******/ __webpack_require__.d(getter, 'a', getter); /******/ return getter; /******/ }; /******/ /******/ // Object.prototype.hasOwnProperty.call /******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; /******/ /******/ // __webpack_public_path__ /******/ __webpack_require__.p = ""; /******/ /******/ /******/ // Load entry module and return exports /******/ return __webpack_require__(__webpack_require__.s = "./testsrc/tests.ts"); /******/ }) /************************************************************************/ /******/ ({ /***/ "./lib/otarray.ts": /*!************************!*\ !*** ./lib/otarray.ts ***! \************************/ /*! no static exports found */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const OT = __webpack_require__(/*! ./ottypes */ "./lib/ottypes.ts"); const TestUnitSize = 4; let TestCounter = 0; // Array Ops exports.OpInsert = 1; exports.OpDelete = 2; exports.OpRetain = 3; exports.OpCursor = 4; // 2nd arg is 0/1 for start/end of region, 3rd arg is clientID exports.OpSet = 5; exports.OpTmpRetain = 6; var OTalignEdgesType; (function (OTalignEdgesType) { OTalignEdgesType[OTalignEdgesType["AlignForCompose"] = 0] = "AlignForCompose"; OTalignEdgesType[OTalignEdgesType["AlignForTransform"] = 1] = "AlignForTransform"; })(OTalignEdgesType || (OTalignEdgesType = {})); ; ; // Operates on a single "OTSingleArrayEdit", parameterized by an object that manipulates the underlying // array-like value stored as the third property of the 3-element edit object. class OTSingleArrayEditor { constructor(raw) { this.raw = raw; } copy(a) { return [a[0], a[1], this.raw.copy(a[2])]; } // Static Predicates for MoveAction isDelete(a) { return a[0] == exports.OpDelete; } isNotDelete(a) { return a[0] != exports.OpDelete; } isCursor(a) { return a[0] == exports.OpCursor; } isNotCursor(a) { return a[0] != exports.OpCursor; } isTmpRetain(a) { return a[0] == exports.OpTmpRetain; } isNotTmpRetainOrDelete(a) { return (a[0] != exports.OpTmpRetain && a[0] != exports.OpDelete); } isTmpRetainOrDelete(a) { return (a[0] == exports.OpTmpRetain || a[0] == exports.OpDelete); } // Other static predicates isIgnore(a) { return a[0] < 0; } isNoOp(a) { return a[1] === 0 && a[0] != exports.OpCursor; } isEqual(a1, a2) { return a1[0] == a2[0] && a1[1] == a2[1] && this.raw.equal(a1[2], a2[2]); } // Helpers appendValue(a, s) { a[2] = this.raw.append(a[2], s); a[1] = a[1] + this.raw.length(s); } empty(a) { a[0] = exports.OpCursor; a[1] = 0; a[2] = this.raw.empty(); } setIgnore(a) { if (a[0] > 0) a[0] = -a[0]; } substr(aIn, pos, len) { let sSource = aIn[2]; if (len > 0 && pos + len <= this.raw.length(sSource)) aIn[2] = this.raw.substr(sSource, pos, len); aIn[1] = len; } substrFromRaw(aIn, pos, len, s) { let sSource = s; if (len > 0 && pos + len <= this.raw.length(sSource)) aIn[2] = this.raw.substr(sSource, pos, len); aIn[1] = len; } copyWithSubstr(aIn, pos, len) { let aOut = this.copy(aIn); this.substr(aOut, pos, len); return aOut; } } exports.OTSingleArrayEditor = OTSingleArrayEditor; ; class OTStringOperations { underlyingTypeName() { return 'string'; } empty() { return ''; } insert(a, pos, aInsert) { let s = a; let sInsert = aInsert; return s.substr(0, pos) + sInsert + s.substr(pos); } delete(a, pos, len) { let s = a; return s.substr(0, pos) + s.substr(pos + len); } set(a, pos, aSet) { let s = a; let sSet = aSet; return s.substr(0, pos) + sSet + s.substr(pos + sSet.length); } append(a, aAppend) { let s = a; let sAppend = aAppend; return s + sAppend; } substr(a, pos, len) { let s = a; return s.substr(pos, len); } substrOf(a, pos, len, aSub) { // a unused if not updated with return value contents let sSub = aSub; return sSub.substr(pos, len); } constructN(n) { let x = ' '; let s = ''; for (;;) { if (n & 1) s += x; n >>= 1; if (n) x += x; else break; } return s; } equal(a1, a2) { let s1 = a1; let s2 = a2; return s1 === s2; } copy(a) { return a; } length(a) { return a.length; } } exports.OTStringOperations = OTStringOperations; ; class OTArrayOperations { underlyingTypeName() { return 'array'; } empty() { return []; } insert(a, pos, aInsert) { let arr = a; let arrInsert = aInsert; let arrReturn = Array(arr.length + arrInsert.length); let i, j; for (i = 0; i < pos; i++) arrReturn[i] = arr[i]; for (j = 0; j < arrInsert.length; j++) arrReturn[i + j] = arrInsert[j]; for (i = pos; i < arr.length; i++) arrReturn[i + j] = arr[i]; return arrReturn; } delete(a, pos, len) { let arr = a; arr.splice(pos, len); return arr; } set(a, pos, aSet) { let arr = a; let arrSet = aSet; for (let i = 0; i < arrSet.length; i++) arr[i + pos] = arrSet[i]; return arr; } append(a, aAppend) { let arr = a; let arrAppend = aAppend; return arr.concat(arrAppend); } substr(a, pos, len) { let arr = a; return arr.slice(pos, pos + len); } substrOf(a, pos, len, aSub) { // a unused if not updated with return value contents let arrSub = aSub; return arrSub.slice(pos, pos + len); } constructN(n) { return new Array(n); } equal(a1, a2) { let arr1 = a1; let arr2 = a2; if (arr1.length != arr2.length) return false; for (let i = 0; i < arr1.length; i++) if (arr1[i] !== arr2[i]) return false; return true; } copy(a) { let arr = a; let arrRet = new Array(arr.length); for (let i = 0; i < arr.length; i++) arrRet[i] = arr[i]; return arrRet; } length(a) { return a.length; } } exports.OTArrayOperations = OTArrayOperations; ; class OTArrayLikeResource extends OT.OTResourceBase { constructor(ed, rname) { super(rname, ed.raw.underlyingTypeName()); this.editor = ed; } copy() { return null; // Needs to be overridden } moveEdits(newA, iStart, iEnd, pred) { if (iEnd == undefined) iEnd = this.edits.length - 1; for (; iStart <= iEnd; iStart++) { let a = this.edits[iStart]; if (!this.editor.isIgnore(a) && (pred == undefined || pred(a))) newA.push(a); } } equal(rhs) { if (this.length != rhs.length) return false; for (let i = 0; i < this.length; i++) if (!this.editor.isEqual(this.edits[i], rhs.edits[i])) return false; return true; } // Function: OTArrayLikeResource::effectivelyEqual // // Description: // A looser definition than operator==. Returns true if two actions would result in the // same final string. This ignores no-ops like OpCursor and allows different orderings of // inserts and deletes at the same location. // // Played around with different algorithms, but the simplest is probably just to apply // the two actions and see if I get the same final string. Came up with an interesting // algorithm of walking through comparing hashes, but that was not robust to operations // being split into fragments and interposed with alternate ops (OpCursor or interleaving of Ins/Del) // that still leave the string the same. If unhappy with this approach (which scales with size // of string to edit rather than complexity of the edit), the other approach would be to canonicalize // the edit operations (including removing cursor operations and normalizing order of deletes). // (Added that version of the algorithm under #ifdef). Could also dynamically choose approach based // on relative size of arrays. // effectivelyEqual(rhs) { // Exactly equal is always effectively equal if (this.equal(rhs)) return true; if (this.originalLength() != rhs.originalLength()) return false; // Preferred algorithm let s = this.editor.raw.constructN(this.originalLength()); let sL = this.apply(s); let sR = rhs.apply(s); return sL === sR; // Alternate algorithm (see above) //let aL: OTArrayLikeResource = this.copy(); //let aR: OTArrayLikeResource = rhs.copy(); //aL.fullyCoalesce(); //aR.fullyCoalesce(); //return aL.equal(aR); } basesConsistent(rhs) { if (this.originalLength() != rhs.originalLength()) { console.log("Logic Failure: transform: Bases Inconsistent."); throw ("Logic Failure: transform: Bases Inconsistent."); } } originalLength() { let len = 0; for (let i = 0; i < this.length; i++) { let a = this.edits[i]; if (a[0] == exports.OpRetain || a[0] == exports.OpDelete || a[0] == exports.OpSet) len += a[1]; } return len; } finalLength() { let len = 0; for (let i = 0; i < this.length; i++) { let a = this.edits[i]; if (a[0] == exports.OpRetain || a[0] == exports.OpInsert || a[0] == exports.OpSet) len += a[1]; } return len; } apply(aValue) { if (aValue == null) aValue = this.editor.raw.empty(); let pos = 0; for (let i = 0; i < this.length; i++) { let a = this.edits[i]; switch (a[0]) { case exports.OpRetain: pos += a[1]; break; case exports.OpCursor: break; case exports.OpDelete: aValue = this.editor.raw.delete(aValue, pos, a[1]); break; case exports.OpInsert: aValue = this.editor.raw.insert(aValue, pos, a[2]); pos += a[1]; break; case exports.OpSet: aValue = this.editor.raw.set(aValue, pos, a[2]); pos += a[1]; break; } } return aValue; } coalesce(bDeleteCursor = false) { if (this.length == 0) return; // coalesce adjoining actions and delete no-ops let newA = []; let aLast; for (let i = 0; i < this.length; i++) { let aNext = this.edits[i]; if (this.editor.isNoOp(aNext) || (bDeleteCursor && aNext[0] == exports.OpCursor)) continue; if (newA.length > 0 && aNext[0] == aLast[0]) { if (aNext[0] == exports.OpInsert || aNext[0] == exports.OpSet) this.editor.appendValue(aLast, aNext[2]); else aLast[1] += aNext[1]; } else { newA.push(aNext); aLast = aNext; } } this.edits = newA; } // Function: fullyCoalesce // // Description: // Heavier duty version of coalesce that fully normalizes so that two actions that result in same // final edit are exactly the same. This normalizes order of insert/deletes and deletes OpCursor, // and then does coalesce. // fullyCoalesce() { // TODO this.coalesce(true); } // Function: Invert // // Description: // Given an action, convert it to its inverse (action + inverse) = identity (retain(n)). // // Note that in order to compute the inverse, you need the input state (e.g. because in order to invert // OpDelete, you need to know the deleted characters. // invert(sInput) { let pos = 0; // Tracks position in input string. for (let i = 0; i < this.length; i++) { let a = this.edits[i]; switch (a[0]) { case exports.OpCursor: break; case exports.OpRetain: pos += a[1]; break; case exports.OpInsert: a[2] = ''; a[0] = exports.OpDelete; break; case exports.OpDelete: a[2] = this.editor.copyWithSubstr(sInput, pos, a[1]); a[0] = exports.OpInsert; pos += a[1]; break; case exports.OpSet: a[2] = this.editor.copyWithSubstr(sInput, pos, a[1]); pos += a[1]; break; } } } // Function: alignEdges // // Description: // Slice up this action sequence so its edges align with the action sequence I am going to // process it with. The processing (compose or transform) determines which actions Slice // takes into account when moving the parallel counters forward. When processing for // compose, deletes in rhs can be ignored. When processing for transform, inserts in both // lhs and rhs can be ignored. // alignEdges(rhs, st) { let posR = 0; let posL = 0; let iL = 0; let newA = []; let aAfter = undefined; let aL = undefined; for (let iR = 0; iR < rhs.length; iR++) { let aR = rhs.edits[iR]; switch (aR[0]) { case exports.OpCursor: break; case exports.OpInsert: break; case exports.OpDelete: posR += aR[1]; break; case exports.OpSet: posR += aR[1]; break; case exports.OpRetain: posR += aR[1]; break; } // Advance iL/posL to equal to posR while (posL < posR && (aAfter != undefined || iL < this.length)) { if (aAfter == undefined) { aL = this.edits[iL]; newA.push(aL); iL++; } else { aL = aAfter; } switch (aL[0]) { case exports.OpCursor: break; case exports.OpInsert: if (st == OTalignEdgesType.AlignForCompose) posL += aL[1]; break; case exports.OpDelete: if (st == OTalignEdgesType.AlignForTransform) posL += aL[1]; break; case exports.OpSet: posL += aL[1]; break; case exports.OpRetain: posL += aL[1]; break; } // Split this one if it spans boundary if (posL > posR) { let nRight = posL - posR; let nLeft = aL[1] - nRight; aAfter = this.editor.copyWithSubstr(aL, nLeft, nRight); this.editor.substr(aL, 0, nLeft); newA.push(aAfter); posL = posR; } else aAfter = undefined; } } // Append any we missed this.moveEdits(newA, iL); this.edits = newA; } getCursorCache() { let cursorCache = {}; for (let i = 0; i < this.length; i++) { let a = this.edits[i]; if (a[0] == exports.OpCursor && a[2] != null) cursorCache[a[2]] = ''; } return cursorCache; } // Function: compose // // Description: // compose the current action with the action passed in. This alters the current action. // // Basic structure is to walk through the RHS list of actions, processing each one in turn. // That then drives the walk through the left hand side and the necessary edits. I use // "posR" and "posL" to work through equivalent positions in the two strings being edited. // Deletions in the LHS don't effect posL because they don't show up in the input string to RHS. // Similarly, insertions in the RHS don't effect posR since they have no equivalent string location // in the LHS. (Note transform follows similar structure but different logic for how posR and posL // track each other since in that case they are effectively referencing the same input string.) // compose(rhs) { let cursorCache = rhs.getCursorCache(); if (this.length == 0) { this.edits = rhs.edits.map(this.editor.copy, this.editor); return; } else if (rhs.edits.length == 0) return; if (this.finalLength() != rhs.originalLength()) { console.log("Logic Failure: compose: Bases Inconsistent."); throw ("Logic Failure: compose: Bases Inconsistent."); } // Break overlapping segments before start to simplify logic below. this.alignEdges(rhs, OTalignEdgesType.AlignForCompose); // Iterate with parallel position markers in two arrays let posR = 0; let posL = 0; let iL = 0; let bDone; let newA = []; for (let iR = 0; iR < rhs.length; iR++) { let aR = rhs.edits[iR]; switch (aR[0]) { case exports.OpRetain: posR += aR[1]; break; case exports.OpSet: case exports.OpDelete: case exports.OpInsert: case exports.OpCursor: // Advance to cursor location bDone = false; while (!bDone && iL < this.length) { let aL = this.edits[iL]; switch (aL[0]) { case exports.OpCursor: // Only copy old cursor locations if they aren't empty and aren't duplicated in this rhs. if (aL[2] != '' && cursorCache[aL[2]] === undefined) newA.push(aL); iL++; break; case exports.OpSet: case exports.OpRetain: case exports.OpInsert: if (posL == posR) bDone = true; else { posL += aL[1]; newA.push(aL); iL++; } break; case exports.OpDelete: newA.push(aL); iL++; // Move past since deletes are not referenced by RHS break; } } if (aR[0] == exports.OpDelete) { // Remove sequence of cursor, insert, retains, sets, replaced by delete. // Note that insert/delete cancel each other out, so there is a bit of complexity there. let nChange = aR[1]; let nRemain = aR[1]; for (; nChange > 0 && iL < this.length; iL++) { let aL = this.edits[iL]; switch (aL[0]) { case exports.OpCursor: // Only copy old cursor locations if they aren't empty and aren't duplicated in this rhs. if (aL[2] != '' && cursorCache[aL[2]] === undefined) newA.push(aL); break; case exports.OpDelete: newA.push(aL); break; case exports.OpSet: case exports.OpRetain: case exports.OpInsert: nRemain -= aL[0] == exports.OpInsert ? aL[1] : 0; nChange -= aL[1]; // Don't copy into new array break; } } // Now add in the delete if (nRemain > 0) newA.push([exports.OpDelete, nRemain, '']); } else if (aR[0] == exports.OpSet) { // Process sequence of cursor, insert, retains, sets let nChange = aR[1]; for (; nChange > 0 && iL < this.length; iL++) { let aL = this.edits[iL]; let opL = exports.OpInsert; switch (aL[0]) { case exports.OpCursor: // Only copy old cursor locations if they aren't empty and aren't duplicated in this rhs. if (aL[2] != '' && cursorCache[aL[2]] === undefined) newA.push(aL); break; case exports.OpDelete: newA.push(aL); break; case exports.OpSet: case exports.OpRetain: opL = exports.OpSet; // fallthrough case exports.OpInsert: // A Set composed with Insert becomes Insert of Set content this.editor.substrFromRaw(aL, aR[1] - nChange, aL[1], aR[2]); aL[0] = opL; nChange -= aL[1]; newA.push(aL); break; } } } else // cursor, insert { // Add in the RHS operation at proper location newA.push(this.editor.copy(aR)); } break; } } // copy any remaining actions, excluding cursors duplicated in rhs this.moveEdits(newA, iL, this.length - 1, function (e) { return (e[0] != exports.OpCursor) || (e[2] != '' && cursorCache[e[2]] === undefined); }); this.edits = newA; this.coalesce(); } performTransformReorder(bForceRetainBeforeInsert, newA, iBegin, iEnd) { if (iBegin < 0 || iBegin > iEnd) return; if (bForceRetainBeforeInsert) { this.moveEdits(newA, iBegin, iEnd, this.editor.isTmpRetainOrDelete); this.moveEdits(newA, iBegin, iEnd, this.editor.isNotTmpRetainOrDelete); // Is Insert or Cursor } else { this.moveEdits(newA, iBegin, iEnd, this.editor.isNotTmpRetainOrDelete); // Is Insert or Cursor this.moveEdits(newA, iBegin, iEnd, this.editor.isTmpRetainOrDelete); } } // Function: normalizeNewRetainsAfterTransform // // Description: // Helper function for transform() that does a post-processing pass to ensure that all // Retains are properly ordered with respect to Inserts that occur at the same location // (either before or after, depending on whether we are transforming based on server or client side). // This ensures that the transform process is not sensitive to precise ordering of Inserts and // Retains (since that ordering doesn't actually change the semantics of the edit performed and // therefore should not result in a difference in processing here). And yes, it's a subtle issue // that may not actually occur in real edits produced by some particular editor but does arise when // testing against randomly generated edit streams. // // A side consequence is also normalizing the ordering of inserts and deletes which also doesn't // change the semantics of the edit but ensures we properly detect conflicting insertions. // // The way to think of this algorithm is that Set's and Retains (pre-existing, not new TmpRetains) form // hard boundaries in the ordering. The series of Cursor/TmpRetain/Insert/Deletes between Sets and Retains // are re-ordered by this algorithm. TmpRetain's get pushed to the front or the back depending on the bForce // flag passed in (which reflects which operation had precedence). // normalizeNewRetainsAfterTransform(bForceRetainBeforeInsert) { if (this.length == 0) return; let i = 0; let newA = []; let iLastEdge = 0; // Normalize ordering for newly insert retains so they are properly ordered // with respect to inserts occurring at the same location. for (i = 0; i < this.length; i++) { let a = this.edits[i]; if (a[0] == exports.OpSet || a[0] == exports.OpRetain) { this.performTransformReorder(bForceRetainBeforeInsert, newA, iLastEdge, i - 1); newA.push(a); iLastEdge = i + 1; } } this.performTransformReorder(bForceRetainBeforeInsert, newA, iLastEdge, this.length - 1); // One last time to switch TmpRetain to Retain for (i = 0; i < newA.length; i++) if ((newA[i])[0] == exports.OpTmpRetain) (newA[i])[0] = exports.OpRetain; this.edits = newA; } transform(prior, bPriorIsService) { if (this.length == 0 || prior.length == 0) return; // Validate this.basesConsistent(prior); // Break overlapping segments before start to simplify logic below. this.alignEdges(prior, OTalignEdgesType.AlignForTransform); let posR = 0; // These walk in parallel across the consistent base strings (only retains, sets and deletes count) let posL = 0; let iL = 0; let bDone; let newA = []; for (let iR = 0; iR < prior.length; iR++) { let aR = prior.edits[iR]; switch (aR[0]) { case exports.OpCursor: // No-op break; case exports.OpInsert: { // Converts to a retain. // Need to find spot to insert retain. After loop, iL will contain location for (; iL < this.length; iL++) { if (posR == posL) break; let aL = this.edits[iL]; if (this.editor.isIgnore(aL)) continue; if (aL[0] != exports.OpCursor && aL[0] != exports.OpInsert) posL += aL[1]; newA.push(aL); } let nRetain = aR[1]; newA.push([exports.OpTmpRetain, nRetain, '']); posR += nRetain; posL += nRetain; } break; case exports.OpSet: // Somewhat unintuitively, if prior is *not* service, then it will actually get applied *after* // the service instance of OpSet and so should take precedence. Therefore if prior is not service, // we need to go through and convert "OpSets" that overlap to be this content. If prior is service, // we can just treat them as "retains" since they have no effect on our operations. if (bPriorIsService) posR += aR[1]; else { let nRemaining = aR[1]; while (nRemaining > 0 && iL < this.length) { let aL = this.edits[iL]; if (this.editor.isIgnore(aL)) { iL++; continue; } let valL = aL[1]; if (aL[0] == exports.OpCursor || aL[0] == exports.OpInsert) { iL++; newA.push(aL); } else { if (posR >= posL + valL) { // Not there yet posL += valL; iL++; newA.push(aL); } else { if (aL[0] == exports.OpDelete || aL[0] == exports.OpRetain) { if (valL <= nRemaining) { posR += valL; posL += valL; nRemaining -= valL; iL++; newA.push(aL); } else { // Not subsumed, but means that I didn't encounter an OpSet posR += nRemaining; nRemaining = 0; } } else // OpSet { if (aL[1] <= nRemaining) { posR += valL; posL += valL; this.editor.substrFromRaw(aL, aR[1] - nRemaining, valL, aR[2]); nRemaining -= valL; iL++; newA.push(aL); } else { // don't advance posL or iL because we will re-process the left over // part for the next action. Simply edit the data in place. // Set [0, nRemaining] of aL.Data to [aR[1]-nRemaining, nRemaining] //aL.Data.delete(0, nRemaining); //aL.Data.InsertValue(0, aR.Data, aR[1]-nRemaining, nRemaining); aL[2] = aR[2].substr(aR[1] - nRemaining) + aL[2].substr(nRemaining); posR += nRemaining; nRemaining = 0; } } } } } } break; case exports.OpDelete: { let nRemaining = aR[1]; let nDelay = 0; let iDelay; // Retains, sets and deletes are subsumed by prior deletes for (; nRemaining > 0 && iL < this.length; iL++) { let aL = this.edits[iL]; if (this.editor.isIgnore(aL)) { if (nDelay > 0) nDelay++; continue; } if (aL[0] == exports.OpCursor || aL[0] == exports.OpInsert) { if (nDelay == 0) iDelay = iL; nDelay++; } else { if (posR >= posL + aL[1]) { // Go ahead and push any delayed actions for (let j = iDelay; nDelay > 0; nDelay--, j++) { let aD = this.edits[j]; if (!this.editor.isIgnore(aD)) newA.push(aD); } // Prior to the deleted content posL += aL[1]; newA.push(aL); } else { // Retain/set/delete is fully subsumed. posR += aL[1]; posL += aL[1]; nRemaining -= aL[1]; this.editor.setIgnore(aL); if (nDelay > 0) nDelay++; } } } // We want to reprocess any trailing insert/cursors so we recognize conflicting inserts even when // deletes intervene. if (nDelay > 0) iL = iDelay; } break; case exports.OpRetain: // Just advance cursor posR += aR[1]; break; } } this.moveEdits(newA, iL); this.edits = newA; this.normalizeNewRetainsAfterTransform(bPriorIsService); this.coalesce(); } // // Function: generateRandom // // Description: // Generate action containing a sequence of retain, insert, delete, cursor with the initial // state of the string being nInitial. Make sure I always generate at least one insert or delete. // Always operate in units of 4 (.123). // generateRandom(nInitial, clientID) { // Ensure clean start this.empty(); // Setup randomizer let nOps = 0; let nLen; let nBound; let s; while (nInitial > 0 || nOps == 0) { let op = randomWithinRange(0, 4); nBound = nInitial / TestUnitSize; if (nInitial == 0 && (op == exports.OpDelete || op == exports.OpRetain || op == exports.OpSet)) continue; switch (op) { case exports.OpInsert: nOps++; nLen = randomWithinRange(1, 5); s = this.editor.raw.empty(); for (let i = 0; i < nLen; i++) s = this.editor.raw.append(s, counterValue(this.editor.raw, TestCounter++)); nLen *= TestUnitSize; this.edits.push([exports.OpInsert, nLen, s]); break; case exports.OpDelete: nOps++; nLen = randomWithinRange(1, nBound > 3 ? nBound / 3 : nBound); nLen *= TestUnitSize; nInitial -= nLen; this.edits.push([exports.OpDelete, nLen, this.editor.raw.empty()]); break; case exports.OpCursor: this.edits.push([exports.OpCursor, 0, clientID]); break; case exports.OpRetain: nLen = randomWithinRange(1, nBound); nLen *= TestUnitSize; nInitial -= nLen; this.edits.push([exports.OpRetain, nLen, this.editor.raw.empty()]); break; case exports.OpSet: nLen = 1; s = this.editor.raw.empty(); for (let i = 0; i < nLen; i++) this.editor.raw.append(s, counterValue(this.editor.raw, TestCounter++)); nLen *= TestUnitSize; nInitial -= nLen; this.edits.push([exports.OpSet, nLen, s]); break; } } // Most importantly ensures canonical ordering of inserts and deletes. this.coalesce(); } } exports.OTArrayLikeResource = OTArrayLikeResource; class OTStringResource extends OTArrayLikeResource { constructor(rname) { super(OTStringResource._editor, rname); } static factory(rname) { return new OTStringResource(rname); } copy() { let copy = new OTStringResource(this.resourceName); copy.edits = this.edits.map(copy.editor.copy, copy.editor); return copy; } } OTStringResource._editor = new OTSingleArrayEditor(new OTStringOperations()); exports.OTStringResource = OTStringResource; class OTArrayResource extends OTArrayLikeResource { constructor(rname) { super(OTArrayResource._editor, rname); } static factory(rname) { return new OTArrayResource(rname); } copy() { let copy = new OTArrayResource(this.resourceName); copy.edits = this.edits.map(copy.editor.copy, copy.editor); return copy; } } OTArrayResource._editor = new OTSingleArrayEditor(new OTArrayOperations()); exports.OTArrayResource = OTArrayResource; function randomWithinRange(nMin, nMax) { return nMin + Math.floor(Math.random() * (nMax - nMin + 1)); } function counterValue(ops, c) { switch (ops.underlyingTypeName()) { case 'string': { let a = new Array(TestUnitSize); a[0] = '.'; for (let j = 1; j < TestUnitSize; j++, c = Math.floor(c / 10)) a[TestUnitSize - j] = "" + (c % 10); return a.join(''); } case 'array': { let a = new Array(TestUnitSize); for (let i = 0; i < TestUnitSize; i++, c += 0.1) a[i] = c; return a; } default: throw "counterValue: Unexpected underlying array-like type."; } } /***/ }), /***/ "./lib/otclientengine.ts": /*!*******************************!*\ !*** ./lib/otclientengine.ts ***! \*******************************/ /*! no static exports found */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const OTC = __webpack_require__(/*! ./otcomposite */ "./lib/otcomposite.ts"); const OTE = __webpack_require__(/*! ./otengine */ "./lib/otengine.ts"); class OTClientEngine extends OTE.OTEngine { // Constructor constructor(ilog, rid, cid) { super(ilog); this.resourceID = rid; this.clientID = cid; this.initialize(); this.bReadOnly = false; this.valCache = {}; } initialize() { this.clientSequenceNo = 0; this.isNeedAck = false; this.isNeedResend = false; this.actionAllClient = new OTC.OTCompositeResource(this.resourceID, this.clientID); this.actionAllPendingClient = new OTC.OTCompositeResource(this.resourceID, this.clientID); this.actionSentClient = new OTC.OTCompositeResource(this.resourceID, this.clientID); this.actionSentClientOriginal = new OTC.OTCompositeResource(this.resourceID, this.clientID); this.actionServerInterposedSentClient = new OTC.OTCompositeResource(this.resourceID, this.clientID); this.stateServer = new OTC.OTCompositeResource(this.resourceID, this.clientID); this.stateLocal = new OTC.OTCompositeResource(this.resourceID, this.clientID); } // Members serverClock() { return this.stateServer.clock; } rid() { return this.resourceID; } cid() { return this.resourceID; } toValue() { return this.valCache; } setReadOnly(b) { if (b != this.bReadOnly) { this.bReadOnly = b; if (this.bReadOnly) this.failbackToServerState(); } } startLocalEdit() { return new OTC.OTCompositeResource(this.resourceID, this.clientID); } isPending() { return this.isNeedResend || !this.actionAllPendingClient.isEmpty(); } getPending() { if (!this.isNeedResend && this.actionAllPendingClient.isEmpty()) return null; else { // If "isNeedResend" I need to send the exact same event (instead of aggregating all pending) // because the server might have actually received and processed the event and I just didn't // receive acknowledgement. If I merge that event into others I'll lose ability to distinguish // that. Eventually when I re-establish communication with server I will get that event response // and can then move on. if (!this.isNeedResend) { this.actionSentClient = this.actionAllPendingClient.copy(); this.actionSentClient.clientSequenceNo = this.clientSequenceNo++; this.actionAllPendingClient.empty(); } this.actionSentClient.clock = this.stateServer.clock; this.actionSentClientOriginal = this.actionSentClient.copy(); this.actionServerInterposedSentClient.empty(); this.isNeedAck = true; this.isNeedResend = false; return this.actionSentClient.copy(); } } // When I fail to send, I need to reset to resend the event again resetPending() { if (this.isNeedAck) { this.isNeedAck = false; this.isNeedResend = true; } } // When I don't accurately have server state - will then refresh from server failbackToInitialState() { this.initialize(); } // When I have server state but my state got mixed up failbackToServerState() { this.stateLocal = this.stateServer.copy(); this.isNeedAck = false; this.actionSentClient.empty(); this.actionSentClientOriginal.empty(); this.actionServerInterposedSentClient.empty(); this.actionAllPendingClient.empty(); this.actionAllClient.empty(); this.valCache = this.stateLocal.toValue(); this.emit('state'); } // // Function: OTClientEngine.addRemote // // Description: // This function is really where the action is in managing the dynamic logic of applying OT. This is run // on each end point and handles the events received from the server. This includes server acknowledgements // (both success and failure) of locally generated events as well as all the events generated from other // clients. // // The key things that happen here are: // 1. Track server state. // 2. Respond to server acknowledgement of locally generated events. This also includes validation // (with failback code) in case where server transformed my event in a way that was inconsistent // with what I expected (due to insert collision that arose due to multiple independent events). // 3. Transform the incoming event (by local events) so it can be applied to local state. // 4. Transform pending local events so they can be dispatched to the service once the service // is ready for another event. // addRemote(orig) { // Reset if server forces restart if (orig.clock == OTC.clockInitialValue) { this.failbackToInitialState(); return; } // Reset if server restarted and we don't sync up if (orig.clock < 0) { // If server didn't lose anything I can just keep going... if (this.stateServer.clock + 1 == -orig.clock) orig.clock = -orig.clock; else { this.failbackToInitialState(); return; } } // Ignore if I've seen this event already if (orig.clock <= this.serverClock()) { return; } let bMine = orig.clientID == this.clientID; let bResend = bMine && orig.clock == OTC.clockFailureValue; let a = orig.copy(); if (bResend) { // Service failed my request. Retry with currently outstanding content. this.resetPending(); return; } try { // Track server state and clock this.stateServer.compose(a); if (bMine) { // Validate that I didn't run into unresolvable conflict if (!this.actionServerInterposedSentClient.isEmpty()) { this.actionSentClientOriginal.transform(this.actionServerInterposedSentClient, true); if (!this.actionSentClient.effectivelyEqual(this.actionSentClientOriginal)) { this.failbackToServerState(); } } // I don't need to apply to local state since it has already been applied - this is just an ack. this.isNeedAck = false; this.actionSentClient.empty(); this.actionSentClientOriginal.empty(); this.actionServerInterposedSentClient.empty(); this.actionAllClient = this.actionAllPendingClient.copy(); } else { // Transform server action to apply locally by transforming