index.js 13 KB

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