index.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. 'use strict'
  2. const _ = require('lodash')
  3. const assert = require('check-types').assert
  4. const EditOperationTransformer = require('./edit_operation_transformer')
  5. // Dependencies are loaded at the bottom of the file to mitigate circular
  6. // dependency
  7. let NoOperation = null
  8. let AddFileOperation = null
  9. let MoveFileOperation = null
  10. let EditFileOperation = null
  11. let SetFileMetadataOperation = null
  12. /**
  13. * @typedef {import("../types").BlobStore} BlobStore
  14. * @typedef {import("../snapshot")} Snapshot
  15. */
  16. /**
  17. * An `Operation` changes a `Snapshot` when it is applied. See the
  18. * {@tutorial OT} tutorial for background.
  19. */
  20. class Operation {
  21. /**
  22. * Deserialize an Operation.
  23. *
  24. * @param {Object} raw
  25. * @return {Operation} one of the subclasses
  26. */
  27. static fromRaw(raw) {
  28. if (Object.prototype.hasOwnProperty.call(raw, 'file')) {
  29. return AddFileOperation.fromRaw(raw)
  30. }
  31. if (Object.prototype.hasOwnProperty.call(raw, 'textOperation')) {
  32. return EditFileOperation.fromRaw(raw)
  33. }
  34. if (Object.prototype.hasOwnProperty.call(raw, 'newPathname')) {
  35. return new MoveFileOperation(raw.pathname, raw.newPathname)
  36. }
  37. if (Object.prototype.hasOwnProperty.call(raw, 'metadata')) {
  38. return new SetFileMetadataOperation(raw.pathname, raw.metadata)
  39. }
  40. if (_.isEmpty(raw)) {
  41. return new NoOperation()
  42. }
  43. throw new Error('invalid raw operation ' + JSON.stringify(raw))
  44. }
  45. /**
  46. * Serialize an Operation.
  47. *
  48. * @return {Object}
  49. */
  50. toRaw() {
  51. return {}
  52. }
  53. /**
  54. * Whether this operation does nothing when applied.
  55. *
  56. * @return {Boolean}
  57. */
  58. isNoOp() {
  59. return false
  60. }
  61. /**
  62. * If this Operation references blob hashes, add them to the given Set.
  63. *
  64. * @param {Set.<String>} blobHashes
  65. */
  66. findBlobHashes(blobHashes) {}
  67. /**
  68. * If this operation references any files, load the files.
  69. *
  70. * @param {string} kind see {File#load}
  71. * @param {BlobStore} blobStore
  72. * @return {Promise<void>}
  73. */
  74. async loadFiles(kind, blobStore) {}
  75. /**
  76. * Return a version of this operation that is suitable for long term storage.
  77. * In most cases, we just need to convert the operation to raw form, but if
  78. * the operation involves File objects, we may need to store their content.
  79. *
  80. * @param {BlobStore} blobStore
  81. * @return {Promise.<Object>}
  82. */
  83. async store(blobStore) {
  84. return this.toRaw()
  85. }
  86. /**
  87. * Apply this Operation to a snapshot.
  88. *
  89. * The snapshot is modified in place.
  90. *
  91. * @param {Snapshot} snapshot
  92. */
  93. applyTo(snapshot) {
  94. assert.object(snapshot, 'bad snapshot')
  95. }
  96. /**
  97. * Whether this operation can be composed with another operation to produce a
  98. * single operation of the same type as this one, while keeping the composed
  99. * operation small and logical enough to be used in the undo stack.
  100. *
  101. * @param {Operation} other
  102. * @return {Boolean}
  103. */
  104. canBeComposedWithForUndo(other) {
  105. return false
  106. }
  107. /**
  108. * Whether this operation can be composed with another operation to produce a
  109. * single operation of the same type as this one.
  110. *
  111. * TODO Moves can be composed. For example, if you rename a to b and then decide
  112. * shortly after that actually you want to call it c, we could compose the two
  113. * to get a -> c). Edits can also be composed --- see rules in TextOperation.
  114. * We also need to consider the Change --- we will need to consider both time
  115. * and author(s) when composing changes. I guess that AddFile can also be
  116. * composed in some cases --- if you upload a file and then decide it was the
  117. * wrong one and upload a new one, we could drop the one in the middle, but
  118. * that seems like a pretty rare case.
  119. *
  120. * @param {Operation} other
  121. * @return {Boolean}
  122. */
  123. canBeComposedWith(other) {
  124. return false
  125. }
  126. /**
  127. * Compose this operation with another operation to produce a single operation
  128. * of the same type as this one.
  129. *
  130. * @param {Operation} other
  131. * @return {Operation}
  132. */
  133. compose(other) {
  134. throw new Error('not implemented')
  135. }
  136. /**
  137. * Transform takes two operations A and B that happened concurrently and
  138. * produces two operations A' and B' (in an array) such that
  139. * `apply(apply(S, A), B') = apply(apply(S, B), A')`.
  140. *
  141. * That is, if one client applies A and then B', they get the same result as
  142. * another client who applies B and then A'.
  143. *
  144. * @param {Operation} a
  145. * @param {Operation} b
  146. * @return {Operation[]} operations `[a', b']`
  147. */
  148. static transform(a, b) {
  149. if (a.isNoOp() || b.isNoOp()) return [b, a]
  150. function transpose(transformer) {
  151. return transformer(b, a).reverse()
  152. }
  153. const bIsAddFile = b instanceof AddFileOperation
  154. const bIsEditFile = b instanceof EditFileOperation
  155. const bIsMoveFile = b instanceof MoveFileOperation
  156. const bIsSetFileMetadata = b instanceof SetFileMetadataOperation
  157. if (a instanceof AddFileOperation) {
  158. if (bIsAddFile) return transformAddFileAddFile(a, b)
  159. if (bIsMoveFile) return transformAddFileMoveFile(a, b)
  160. if (bIsEditFile) return transformAddFileEditFile(a, b)
  161. if (bIsSetFileMetadata) return transformAddFileSetFileMetadata(a, b)
  162. throw new Error('bad op b')
  163. }
  164. if (a instanceof MoveFileOperation) {
  165. if (bIsAddFile) return transpose(transformAddFileMoveFile)
  166. if (bIsMoveFile) return transformMoveFileMoveFile(a, b)
  167. if (bIsEditFile) return transformMoveFileEditFile(a, b)
  168. if (bIsSetFileMetadata) return transformMoveFileSetFileMetadata(a, b)
  169. throw new Error('bad op b')
  170. }
  171. if (a instanceof EditFileOperation) {
  172. if (bIsAddFile) return transpose(transformAddFileEditFile)
  173. if (bIsMoveFile) return transpose(transformMoveFileEditFile)
  174. if (bIsEditFile) return transformEditFileEditFile(a, b)
  175. if (bIsSetFileMetadata) return transformEditFileSetFileMetadata(a, b)
  176. throw new Error('bad op b')
  177. }
  178. if (a instanceof SetFileMetadataOperation) {
  179. if (bIsAddFile) return transpose(transformAddFileSetFileMetadata)
  180. if (bIsMoveFile) return transpose(transformMoveFileSetFileMetadata)
  181. if (bIsEditFile) return transpose(transformEditFileSetFileMetadata)
  182. if (bIsSetFileMetadata) return transformSetFileMetadatas(a, b)
  183. throw new Error('bad op b')
  184. }
  185. throw new Error('bad op a')
  186. }
  187. /**
  188. * Transform each operation in `a` by each operation in `b` and save the primed
  189. * operations in place.
  190. *
  191. * @param {Array.<Operation>} as - modified in place
  192. * @param {Array.<Operation>} bs - modified in place
  193. */
  194. static transformMultiple(as, bs) {
  195. for (let i = 0; i < as.length; ++i) {
  196. for (let j = 0; j < bs.length; ++j) {
  197. const primes = Operation.transform(as[i], bs[j])
  198. as[i] = primes[0]
  199. bs[j] = primes[1]
  200. }
  201. }
  202. }
  203. static addFile(pathname, file) {
  204. return new AddFileOperation(pathname, file)
  205. }
  206. static editFile(pathname, editOperation) {
  207. return new EditFileOperation(pathname, editOperation)
  208. }
  209. static moveFile(pathname, newPathname) {
  210. return new MoveFileOperation(pathname, newPathname)
  211. }
  212. static removeFile(pathname) {
  213. return new MoveFileOperation(pathname, '')
  214. }
  215. static setFileMetadata(pathname, metadata) {
  216. return new SetFileMetadataOperation(pathname, metadata)
  217. }
  218. }
  219. //
  220. // Transform
  221. //
  222. // The way to read these transform functions is that
  223. // 1. return_value[0] is the op to be applied after arguments[1], and
  224. // 2. return_value[1] is the op to be applied after arguments[0],
  225. // in order to arrive at the same project state.
  226. //
  227. function transformAddFileAddFile(add1, add2) {
  228. if (add1.getPathname() === add2.getPathname()) {
  229. return [Operation.NO_OP, add2] // add2 wins
  230. }
  231. return [add1, add2]
  232. }
  233. function transformAddFileMoveFile(add, move) {
  234. function relocateAddFile() {
  235. return new AddFileOperation(move.getNewPathname(), add.getFile().clone())
  236. }
  237. if (add.getPathname() === move.getPathname()) {
  238. if (move.isRemoveFile()) {
  239. return [add, Operation.NO_OP]
  240. }
  241. return [
  242. relocateAddFile(),
  243. new MoveFileOperation(add.getPathname(), move.getNewPathname()),
  244. ]
  245. }
  246. if (add.getPathname() === move.getNewPathname()) {
  247. return [relocateAddFile(), new MoveFileOperation(move.getPathname(), '')]
  248. }
  249. return [add, move]
  250. }
  251. function transformAddFileEditFile(add, edit) {
  252. if (add.getPathname() === edit.getPathname()) {
  253. return [add, Operation.NO_OP] // the add wins
  254. }
  255. return [add, edit]
  256. }
  257. function transformAddFileSetFileMetadata(add, set) {
  258. if (add.getPathname() === set.getPathname()) {
  259. const newFile = add.getFile().clone()
  260. newFile.setMetadata(set.getMetadata())
  261. return [new AddFileOperation(add.getPathname(), newFile), set]
  262. }
  263. return [add, set]
  264. }
  265. //
  266. // This is one of the trickier ones. There are 15 possible equivalence
  267. // relationships between our four variables:
  268. //
  269. // path1, newPath1, path2, newPath2 --- "same move" (all equal)
  270. //
  271. // path1, newPath1, path2 | newPath2 --- "no-ops" (1)
  272. // path1, newPath1, newPath2 | path2 --- "no-ops" (1)
  273. // path1, path2, newPath2 | newPath1 --- "no-ops" (2)
  274. // newPath1, path2, newPath2 | path1 --- "no-ops" (2)
  275. //
  276. // path1, newPath1 | path2, newPath2 --- "no-ops" (1 and 2)
  277. // path1, path2 | newPath1, newPath2 --- "same move"
  278. // path1, newPath2 | newPath1, path2 --- "opposite moves"
  279. //
  280. // path1, newPath1 | path2 | newPath2 --- "no-ops" (1)
  281. // path1, path2 | newPath1 | newPath2 --- "divergent moves"
  282. // path1, newPath2 | newPath1 | path2 --- "transitive move"
  283. // newPath1, path2 | path1 | newPath2 --- "transitive move"
  284. // newPath1, newPath2 | path1 | path2 --- "convergent move"
  285. // path2, newPath2 | path1 | newPath1 --- "no-ops" (2)
  286. //
  287. // path1 | newPath1 | path2 | newPath2 --- "no conflict"
  288. //
  289. function transformMoveFileMoveFile(move1, move2) {
  290. const path1 = move1.getPathname()
  291. const path2 = move2.getPathname()
  292. const newPath1 = move1.getNewPathname()
  293. const newPath2 = move2.getNewPathname()
  294. // the same move
  295. if (path1 === path2 && newPath1 === newPath2) {
  296. return [Operation.NO_OP, Operation.NO_OP]
  297. }
  298. // no-ops
  299. if (path1 === newPath1 && path2 === newPath2) {
  300. return [Operation.NO_OP, Operation.NO_OP]
  301. }
  302. if (path1 === newPath1) {
  303. return [Operation.NO_OP, move2]
  304. }
  305. if (path2 === newPath2) {
  306. return [move1, Operation.NO_OP]
  307. }
  308. // opposite moves (foo -> bar, bar -> foo)
  309. if (path1 === newPath2 && path2 === newPath1) {
  310. // We can't handle this very well: if we wanted move2 (say) to win, move2'
  311. // would have to be addFile(foo) with the content of bar, but we don't have
  312. // the content of bar available here. So, we just destroy both files.
  313. return [Operation.removeFile(path1), Operation.removeFile(path2)]
  314. }
  315. // divergent moves (foo -> bar, foo -> baz); convention: move2 wins
  316. if (path1 === path2 && newPath1 !== newPath2) {
  317. return [Operation.NO_OP, Operation.moveFile(newPath1, newPath2)]
  318. }
  319. // convergent move (foo -> baz, bar -> baz); convention: move2 wins
  320. if (newPath1 === newPath2 && path1 !== path2) {
  321. return [Operation.removeFile(path1), move2]
  322. }
  323. // transitive move:
  324. // 1: foo -> baz, 2: bar -> foo (result: bar -> baz) or
  325. // 1: foo -> bar, 2: bar -> baz (result: foo -> baz)
  326. if (path1 === newPath2 && newPath1 !== path2) {
  327. return [
  328. Operation.moveFile(newPath2, newPath1),
  329. Operation.moveFile(path2, newPath1),
  330. ]
  331. }
  332. if (newPath1 === path2 && path1 !== newPath2) {
  333. return [
  334. Operation.moveFile(path1, newPath2),
  335. Operation.moveFile(newPath1, newPath2),
  336. ]
  337. }
  338. // no conflict
  339. return [move1, move2]
  340. }
  341. function transformMoveFileEditFile(move, edit) {
  342. if (move.getPathname() === edit.getPathname()) {
  343. if (move.isRemoveFile()) {
  344. // let the remove win
  345. return [move, Operation.NO_OP]
  346. }
  347. return [
  348. move,
  349. Operation.editFile(move.getNewPathname(), edit.getOperation()),
  350. ]
  351. }
  352. if (move.getNewPathname() === edit.getPathname()) {
  353. // let the move win
  354. return [move, Operation.NO_OP]
  355. }
  356. return [move, edit]
  357. }
  358. function transformMoveFileSetFileMetadata(move, set) {
  359. if (move.getPathname() === set.getPathname()) {
  360. return [
  361. move,
  362. Operation.setFileMetadata(move.getNewPathname(), set.getMetadata()),
  363. ]
  364. }
  365. // A: mv foo -> bar
  366. // B: set bar.x
  367. //
  368. // A': mv foo -> bar
  369. // B': nothing
  370. if (move.getNewPathname() === set.getPathname()) {
  371. return [move, Operation.NO_OP] // let the move win
  372. }
  373. return [move, set]
  374. }
  375. function transformEditFileEditFile(edit1, edit2) {
  376. if (edit1.getPathname() === edit2.getPathname()) {
  377. const primeOps = EditOperationTransformer.transform(
  378. edit1.getOperation(),
  379. edit2.getOperation()
  380. )
  381. return [
  382. Operation.editFile(edit1.getPathname(), primeOps[0]),
  383. Operation.editFile(edit2.getPathname(), primeOps[1]),
  384. ]
  385. }
  386. return [edit1, edit2]
  387. }
  388. function transformEditFileSetFileMetadata(edit, set) {
  389. // There is no conflict.
  390. return [edit, set]
  391. }
  392. function transformSetFileMetadatas(set1, set2) {
  393. if (set1.getPathname() === set2.getPathname()) {
  394. return [Operation.NO_OP, set2] // set2 wins
  395. }
  396. return [set1, set2]
  397. }
  398. module.exports = Operation
  399. // Work around circular import
  400. NoOperation = require('./no_operation')
  401. AddFileOperation = require('./add_file_operation')
  402. MoveFileOperation = require('./move_file_operation')
  403. EditFileOperation = require('./edit_file_operation')
  404. SetFileMetadataOperation = require('./set_file_metadata_operation')
  405. Operation.NO_OP = new NoOperation()