change.js 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. 'use strict'
  2. const _ = require('lodash')
  3. const assert = require('check-types').assert
  4. const pMap = require('p-map')
  5. const AuthorList = require('./author_list')
  6. const Operation = require('./operation')
  7. const Origin = require('./origin')
  8. const Snapshot = require('./snapshot')
  9. const FileMap = require('./file_map')
  10. const V2DocVersions = require('./v2_doc_versions')
  11. /**
  12. * @typedef {import("./author")} Author
  13. * @typedef {import("./types").BlobStore} BlobStore
  14. */
  15. /**
  16. * A Change is a list of {@link Operation}s applied atomically by given
  17. * {@link Author}(s) at a given time.
  18. */
  19. class Change {
  20. static PROJECT_VERSION_RX_STRING = '^[0-9]+\\.[0-9]+$'
  21. static PROJECT_VERSION_RX = new RegExp(Change.PROJECT_VERSION_RX_STRING)
  22. /**
  23. * @param {Array.<Operation>} operations
  24. * @param {Date} timestamp
  25. * @param {number[] | Author[]} [authors]
  26. * @param {Origin} [origin]
  27. * @param {string[]} [v2Authors]
  28. * @param {string} [projectVersion]
  29. * @param {V2DocVersions} [v2DocVersions]
  30. */
  31. constructor(
  32. operations,
  33. timestamp,
  34. authors,
  35. origin,
  36. v2Authors,
  37. projectVersion,
  38. v2DocVersions
  39. ) {
  40. this.setOperations(operations)
  41. this.setTimestamp(timestamp)
  42. this.setAuthors(authors || [])
  43. this.setOrigin(origin)
  44. this.setV2Authors(v2Authors || [])
  45. this.setProjectVersion(projectVersion)
  46. this.setV2DocVersions(v2DocVersions)
  47. }
  48. /**
  49. * For serialization.
  50. *
  51. * @return {Object}
  52. */
  53. toRaw() {
  54. function toRaw(object) {
  55. return object.toRaw()
  56. }
  57. const raw = {
  58. operations: this.operations.map(toRaw),
  59. timestamp: this.timestamp.toISOString(),
  60. authors: this.authors,
  61. }
  62. if (this.v2Authors) raw.v2Authors = this.v2Authors
  63. if (this.origin) raw.origin = this.origin.toRaw()
  64. if (this.projectVersion) raw.projectVersion = this.projectVersion
  65. if (this.v2DocVersions) raw.v2DocVersions = this.v2DocVersions.toRaw()
  66. return raw
  67. }
  68. static fromRaw(raw) {
  69. if (!raw) return null
  70. assert.array.of.object(raw.operations, 'bad raw.operations')
  71. assert.nonEmptyString(raw.timestamp, 'bad raw.timestamp')
  72. // Hack to clean up bad data where author id of some changes was 0, instead of
  73. // null. The root cause of the bug is fixed in
  74. // https://github.com/overleaf/write_latex/pull/3804 but the bad data persists
  75. // on S3
  76. let authors
  77. if (raw.authors) {
  78. authors = raw.authors.map(
  79. // Null represents an anonymous author
  80. author => (author === 0 ? null : author)
  81. )
  82. }
  83. return new Change(
  84. raw.operations.map(Operation.fromRaw),
  85. new Date(raw.timestamp),
  86. authors,
  87. raw.origin && Origin.fromRaw(raw.origin),
  88. raw.v2Authors,
  89. raw.projectVersion,
  90. raw.v2DocVersions && V2DocVersions.fromRaw(raw.v2DocVersions)
  91. )
  92. }
  93. getOperations() {
  94. return this.operations
  95. }
  96. setOperations(operations) {
  97. assert.array.of.object(operations, 'Change: bad operations')
  98. this.operations = operations
  99. }
  100. getTimestamp() {
  101. return this.timestamp
  102. }
  103. setTimestamp(timestamp) {
  104. assert.date(timestamp, 'Change: bad timestamp')
  105. this.timestamp = timestamp
  106. }
  107. /**
  108. * @return {Array.<Author>} zero or more
  109. */
  110. getAuthors() {
  111. return this.authors
  112. }
  113. setAuthors(authors) {
  114. assert.array(authors, 'Change: bad author ids array')
  115. if (authors.length > 1) {
  116. assert.maybe.emptyArray(
  117. this.v2Authors,
  118. 'Change: cannot set v1 authors if v2 authors is set'
  119. )
  120. }
  121. AuthorList.assertV1(authors, 'Change: bad author ids')
  122. this.authors = authors
  123. }
  124. /**
  125. * @return {Array.<Author>} zero or more
  126. */
  127. getV2Authors() {
  128. return this.v2Authors
  129. }
  130. setV2Authors(v2Authors) {
  131. assert.array(v2Authors, 'Change: bad v2 author ids array')
  132. if (v2Authors.length > 1) {
  133. assert.maybe.emptyArray(
  134. this.authors,
  135. 'Change: cannot set v2 authors if v1 authors is set'
  136. )
  137. }
  138. AuthorList.assertV2(v2Authors, 'Change: not a v2 author id')
  139. this.v2Authors = v2Authors
  140. }
  141. /**
  142. * @return {Origin | null | undefined}
  143. */
  144. getOrigin() {
  145. return this.origin
  146. }
  147. setOrigin(origin) {
  148. assert.maybe.instance(origin, Origin, 'Change: bad origin')
  149. this.origin = origin
  150. }
  151. /**
  152. * @return {string | null | undefined}
  153. */
  154. getProjectVersion() {
  155. return this.projectVersion
  156. }
  157. setProjectVersion(projectVersion) {
  158. assert.maybe.match(
  159. projectVersion,
  160. Change.PROJECT_VERSION_RX,
  161. 'Change: bad projectVersion'
  162. )
  163. this.projectVersion = projectVersion
  164. }
  165. /**
  166. * @return {V2DocVersions | null | undefined}
  167. */
  168. getV2DocVersions() {
  169. return this.v2DocVersions
  170. }
  171. setV2DocVersions(v2DocVersions) {
  172. assert.maybe.instance(
  173. v2DocVersions,
  174. V2DocVersions,
  175. 'Change: bad v2DocVersions'
  176. )
  177. this.v2DocVersions = v2DocVersions
  178. }
  179. /**
  180. * If this Change references blob hashes, add them to the given set.
  181. *
  182. * @param {Set.<String>} blobHashes
  183. */
  184. findBlobHashes(blobHashes) {
  185. for (const operation of this.operations) {
  186. operation.findBlobHashes(blobHashes)
  187. }
  188. }
  189. /**
  190. * If this Change contains any File objects, load them.
  191. *
  192. * @param {string} kind see {File#load}
  193. * @param {BlobStore} blobStore
  194. * @return {Promise<void>}
  195. */
  196. async loadFiles(kind, blobStore) {
  197. for (const operation of this.operations) {
  198. await operation.loadFiles(kind, blobStore)
  199. }
  200. }
  201. /**
  202. * Append an operation to the end of the operations list.
  203. *
  204. * @param {Operation} operation
  205. * @return {this}
  206. */
  207. pushOperation(operation) {
  208. this.getOperations().push(operation)
  209. return this
  210. }
  211. /**
  212. * Apply this change to a snapshot. All operations are applied, and then the
  213. * snapshot version is increased.
  214. *
  215. * Recoverable errors (caused by historical bad data) are ignored unless
  216. * opts.strict is true
  217. *
  218. * @param {Snapshot} snapshot modified in place
  219. * @param {object} opts
  220. * @param {boolean} [opts.strict] - Do not ignore recoverable errors
  221. */
  222. applyTo(snapshot, opts = {}) {
  223. assert.object(snapshot, 'bad snapshot')
  224. for (const operation of this.operations) {
  225. try {
  226. operation.applyTo(snapshot, opts)
  227. } catch (err) {
  228. const recoverable =
  229. err instanceof Snapshot.EditMissingFileError ||
  230. err instanceof FileMap.FileNotFoundError
  231. if (!recoverable || opts.strict) {
  232. throw err
  233. }
  234. }
  235. }
  236. // update project version if present in change
  237. if (this.projectVersion) {
  238. snapshot.setProjectVersion(this.projectVersion)
  239. }
  240. // update doc versions
  241. if (this.v2DocVersions) {
  242. snapshot.updateV2DocVersions(this.v2DocVersions)
  243. }
  244. }
  245. /**
  246. * Transform this change to account for the fact that the other change occurred
  247. * simultaneously and was applied first.
  248. *
  249. * This change is modified in place (by transforming its operations).
  250. *
  251. * @param {Change} other
  252. */
  253. transformAfter(other) {
  254. assert.object(other, 'bad other')
  255. const thisOperations = this.getOperations()
  256. const otherOperations = other.getOperations()
  257. for (let i = 0; i < otherOperations.length; ++i) {
  258. for (let j = 0; j < thisOperations.length; ++j) {
  259. thisOperations[j] = Operation.transform(
  260. thisOperations[j],
  261. otherOperations[i]
  262. )[0]
  263. }
  264. }
  265. }
  266. clone() {
  267. return Change.fromRaw(this.toRaw())
  268. }
  269. async store(blobStore, concurrency) {
  270. assert.maybe.number(concurrency, 'bad concurrency')
  271. const raw = this.toRaw()
  272. raw.authors = _.uniq(raw.authors)
  273. const rawOperations = await pMap(
  274. this.operations,
  275. operation => operation.store(blobStore),
  276. { concurrency: concurrency || 1 }
  277. )
  278. raw.operations = rawOperations
  279. return raw
  280. }
  281. canBeComposedWith(other) {
  282. const operations = this.getOperations()
  283. const otherOperations = other.getOperations()
  284. // We ignore complex changes with more than 1 operation
  285. if (operations.length > 1 || otherOperations.length > 1) return false
  286. return operations[0].canBeComposedWith(otherOperations[0])
  287. }
  288. }
  289. module.exports = Change