index.js 13 KB

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