doh.js 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. "use strict";
  2. const { v4: uuidv4 } = require("uuid");
  3. class Operation {
  4. constructor(id, detail) {
  5. this.id = id;
  6. this.detail = detail;
  7. }
  8. // Basically replaces JS references by IDs.
  9. // Result can be JSON'd with constant time+space complexity. Useful for sharing an edit over the network.
  10. serialize(op) {
  11. const self = this; // workaround
  12. return {
  13. id: this.id,
  14. detail: Object.fromEntries(
  15. (function*() {
  16. for (const [key, {value, parent, depth}] of self.detail.entries()) {
  17. yield [key, {
  18. value,
  19. parentId: parent.id,
  20. depth,
  21. }];
  22. }
  23. })()),
  24. }
  25. }
  26. }
  27. class Context {
  28. constructor(fetchCallback) {
  29. // Must be a function taking a single 'id' parameter, returning a Promise resolving to the serialized operation with the given id.
  30. this.fetchCallback = fetchCallback;
  31. // "Global" stuff. Operations have GUIDs but can also be shared between Histories. For instance, the 'initial' operation is the common root of all model histories. We could have put these things in a global variable, but that would make it more difficult to mock 'remoteness' (separate contexts) in tests.
  32. this.initialOp = new Operation("0", new Map()); // The parent of all parentless Operations. Root of all histories.
  33. this.ops = new Map(); // contains all pending or resolved operation-requests; mapping from operation-id to Promise resolving to Operation.
  34. this.ops.set(this.initialOp.id, Promise.resolve(this.initialOp));
  35. }
  36. // Get a promise resolving to the Operation with given ID. Fetches the operation (and recursively its dependencies) if necessary. Resolves when the operation and all its dependencies are present. Idempotent.
  37. requestOperation(id) {
  38. let promise = this.ops.get(id);
  39. if (promise === undefined) {
  40. promise = this.fetchCallback(id).then(serialized => this._awaitParents(serialized));
  41. this.ops.set(id, promise);
  42. }
  43. return promise;
  44. }
  45. // Similar to requestOperation, but instead the argument is an already fetched/received operation. Missing dependencies are (recursively) fetched, if necessary. Resolves when the operation and all its dependencies are present. Idempotent.
  46. receiveOperation(serialized) {
  47. let promise = this.ops.get(serialized.id);
  48. if (promise === undefined) {
  49. promise = this._awaitParents(serialized);
  50. this.ops.set(serialized.id, promise);
  51. }
  52. return promise;
  53. }
  54. // Internal function. Do not use directly.
  55. async _awaitParents({id, detail}) {
  56. const dependencies = Object.entries(detail).map(async ([key, {value, parentId, depth}]) => {
  57. return [key, {
  58. value,
  59. parent: await this.requestOperation(parentId),
  60. depth,
  61. }];
  62. });
  63. return new Operation(id, new Map(await Promise.all(dependencies)));
  64. }
  65. }
  66. class History {
  67. constructor(context, setState, resolve) {
  68. this.context = context;
  69. // callbacks
  70. this.setState = setState;
  71. this.resolve = resolve;
  72. this.heads = new Map(); // HEAD ptrs; mapping from key to Operation
  73. this.ops = new Map(); // Operations (winning and losing) that happened within this History.
  74. this.ops.set(context.initialOp.id, context.initialOp);
  75. this.childrenMapping = new Map(); // mapping from operation to object mapping key to current winning child.
  76. }
  77. _getHead(key) {
  78. const op = this.heads.get(key);
  79. if (op !== undefined) {
  80. return {
  81. op,
  82. depth: op.detail.get(key).depth,
  83. };
  84. };
  85. return {
  86. op: this.context.initialOp,
  87. depth: 0,
  88. };
  89. }
  90. _update_head(op) {
  91. for (const [key, {value}] of op.detail.entries()) {
  92. this.heads.set(key, op);
  93. }
  94. }
  95. _update_state(op) {
  96. for (const [key, {value}] of op.detail.entries()) {
  97. this.setState(key, value);
  98. }
  99. }
  100. _setChild(parent, key, child) {
  101. let childMap = this.childrenMapping.get(parent);
  102. if (childMap === undefined) {
  103. childMap = {};
  104. this.childrenMapping.set(parent, childMap);
  105. }
  106. childMap[key] = child;
  107. }
  108. _getChild(parent, key) {
  109. let childMap = this.childrenMapping.get(parent);
  110. if (childMap === undefined) return;
  111. return childMap[key];
  112. }
  113. // To be called when a new user operation has happened locally.
  114. // The new operation advances HEADs.
  115. new(v, updateState=true) {
  116. const newId = uuidv4();
  117. const detail = new Map(Object.entries(v).map(([key,value]) => {
  118. const {op: parent, depth} = this._getHead(key);
  119. return [key, {
  120. value,
  121. parent,
  122. depth: depth + 1,
  123. }];
  124. }));
  125. const newOp = new Operation(newId, detail);
  126. for (const [key, {parent}] of detail.entries()) {
  127. this._setChild(parent, key, newOp);
  128. }
  129. this._update_head(newOp);
  130. if (updateState) {
  131. this._update_state(newOp);
  132. }
  133. this.context.ops.set(newId, Promise.resolve(newOp));
  134. this.ops.set(newId, newOp);
  135. return newOp;
  136. }
  137. // Idempotent.
  138. autoMerge(op) {
  139. if (this.ops.has(op.id)) {
  140. // Already merged -> skip
  141. // console.log('skip (already merged)', op.id)
  142. return;
  143. }
  144. let exec = true;
  145. for (const [key, {parent}] of op.detail.entries()) {
  146. if (!this.ops.has(parent.id)) {
  147. // Update this History with operation's dependencies first
  148. this.autoMerge(parent);
  149. }
  150. // Check if there's a concurrent sibling with whom there is a conflict
  151. const sibling = this._getChild(parent, key);
  152. if (sibling) {
  153. // Conflict
  154. if (this.resolve(op, sibling)) {
  155. // console.log("conflict: op wins")
  156. const visited = new Set();
  157. const rollback = op => {
  158. visited.add(op); // Children form a DAG, with possible 'diamond' shapes -> prevent same operation from being visited more than once.
  159. for (const [key, {parent}] of op.detail.entries()) {
  160. // recurse, child-first
  161. const child = this._getChild(op, key);
  162. if (child && !visited.has(child)) {
  163. // (DFS) recursion
  164. rollback(child);
  165. }
  166. // rollback
  167. if (parent === this.context.initialOp) {
  168. // Invariant: HEADs never contains initialOp
  169. this.heads.delete(key);
  170. this.setState(key, undefined);
  171. } else {
  172. this.heads.set(key, parent);
  173. this.setState(key, parent.detail.get(key).value);
  174. }
  175. }
  176. };
  177. // Received operation wins conflict - state must be rolled back before executing it
  178. rollback(sibling);
  179. } else {
  180. // Received operation loses conflict - nothing to be done
  181. // console.log("conflict: op loses")
  182. exec = false;
  183. continue;
  184. }
  185. } else {
  186. // console.log('no conflict')
  187. }
  188. // won (or no conflict):
  189. this._setChild(parent, key, op);
  190. if (parent !== this._getHead(key).op) {
  191. // only execute received operation if it advances HEAD
  192. exec = false;
  193. }
  194. }
  195. if (exec) {
  196. this._update_head(op);
  197. this._update_state(op);
  198. }
  199. this.ops.set(op.id, op);
  200. }
  201. // Shorthand
  202. async receiveAndMerge(serializedOp) {
  203. const op = await this.context.receiveOperation(serializedOp);
  204. this.autoMerge(op);
  205. return op;
  206. }
  207. // Get operations in history in a sequence, such that any operation's dependencies precede it in the list. To reproduce the state of this History, operations can be executed in the returned order (front to back), and are guaranteed to not give conflicts.
  208. getOpsSequence() {
  209. const added = new Set([this.context.initialOp]);
  210. const visiting = new Set();
  211. const seq = [];
  212. const visit = op => {
  213. if (!added.has(op)) {
  214. visiting.add(op);
  215. for (const [key, {parent}] of op.detail.entries()) {
  216. visit(parent);
  217. }
  218. seq.push(op);
  219. added.add(op);
  220. }
  221. }
  222. for (const op of this.heads.values()) {
  223. visit(op);
  224. }
  225. return seq;
  226. }
  227. }
  228. module.exports = { Context, History, uuidv4 };