change.js 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  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. * @import Author from "./author"
  13. * @import { BlobStore } from "./types"
  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. /**
  94. * @return {Operation[]}
  95. */
  96. getOperations() {
  97. return this.operations
  98. }
  99. setOperations(operations) {
  100. assert.array.of.object(operations, 'Change: bad operations')
  101. this.operations = operations
  102. }
  103. getTimestamp() {
  104. return this.timestamp
  105. }
  106. setTimestamp(timestamp) {
  107. assert.date(timestamp, 'Change: bad timestamp')
  108. this.timestamp = timestamp
  109. }
  110. /**
  111. * @return {Array.<Author>} zero or more
  112. */
  113. getAuthors() {
  114. return this.authors
  115. }
  116. setAuthors(authors) {
  117. assert.array(authors, 'Change: bad author ids array')
  118. if (authors.length > 1) {
  119. assert.maybe.emptyArray(
  120. this.v2Authors,
  121. 'Change: cannot set v1 authors if v2 authors is set'
  122. )
  123. }
  124. AuthorList.assertV1(authors, 'Change: bad author ids')
  125. this.authors = authors
  126. }
  127. /**
  128. * @return {Array.<Author>} zero or more
  129. */
  130. getV2Authors() {
  131. return this.v2Authors
  132. }
  133. setV2Authors(v2Authors) {
  134. assert.array(v2Authors, 'Change: bad v2 author ids array')
  135. if (v2Authors.length > 1) {
  136. assert.maybe.emptyArray(
  137. this.authors,
  138. 'Change: cannot set v2 authors if v1 authors is set'
  139. )
  140. }
  141. AuthorList.assertV2(v2Authors, 'Change: not a v2 author id')
  142. this.v2Authors = v2Authors
  143. }
  144. /**
  145. * @return {Origin | null | undefined}
  146. */
  147. getOrigin() {
  148. return this.origin
  149. }
  150. setOrigin(origin) {
  151. assert.maybe.instance(origin, Origin, 'Change: bad origin')
  152. this.origin = origin
  153. }
  154. /**
  155. * @return {string | null | undefined}
  156. */
  157. getProjectVersion() {
  158. return this.projectVersion
  159. }
  160. setProjectVersion(projectVersion) {
  161. assert.maybe.match(
  162. projectVersion,
  163. Change.PROJECT_VERSION_RX,
  164. 'Change: bad projectVersion'
  165. )
  166. this.projectVersion = projectVersion
  167. }
  168. /**
  169. * @return {V2DocVersions | null | undefined}
  170. */
  171. getV2DocVersions() {
  172. return this.v2DocVersions
  173. }
  174. setV2DocVersions(v2DocVersions) {
  175. assert.maybe.instance(
  176. v2DocVersions,
  177. V2DocVersions,
  178. 'Change: bad v2DocVersions'
  179. )
  180. this.v2DocVersions = v2DocVersions
  181. }
  182. /**
  183. * If this Change references blob hashes, add them to the given set.
  184. *
  185. * @param {Set.<String>} blobHashes
  186. */
  187. findBlobHashes(blobHashes) {
  188. for (const operation of this.operations) {
  189. operation.findBlobHashes(blobHashes)
  190. }
  191. }
  192. /**
  193. * If this Change contains any File objects, load them.
  194. *
  195. * @param {string} kind see {File#load}
  196. * @param {BlobStore} blobStore
  197. * @return {Promise<void>}
  198. */
  199. async loadFiles(kind, blobStore) {
  200. for (const operation of this.operations) {
  201. await operation.loadFiles(kind, blobStore)
  202. }
  203. }
  204. /**
  205. * Append an operation to the end of the operations list.
  206. *
  207. * @param {Operation} operation
  208. * @return {this}
  209. */
  210. pushOperation(operation) {
  211. this.getOperations().push(operation)
  212. return this
  213. }
  214. /**
  215. * Apply this change to a snapshot. All operations are applied, and then the
  216. * snapshot version is increased.
  217. *
  218. * Recoverable errors (caused by historical bad data) are ignored unless
  219. * opts.strict is true
  220. *
  221. * @param {Snapshot} snapshot modified in place
  222. * @param {object} opts
  223. * @param {boolean} [opts.strict] - Do not ignore recoverable errors
  224. */
  225. applyTo(snapshot, opts = {}) {
  226. // eslint-disable-next-line no-unused-vars
  227. for (const operation of this.iterativelyApplyTo(snapshot, opts)) {
  228. // Nothing to do: we're just consuming the iterator for the side effects
  229. }
  230. }
  231. /**
  232. * Generator that applies this change to a snapshot and yields each
  233. * operation after it has been applied.
  234. *
  235. * Recoverable errors (caused by historical bad data) are ignored unless
  236. * opts.strict is true
  237. *
  238. * @param {Snapshot} snapshot modified in place
  239. * @param {object} opts
  240. * @param {boolean} [opts.strict] - Do not ignore recoverable errors
  241. */
  242. *iterativelyApplyTo(snapshot, opts = {}) {
  243. assert.object(snapshot, 'bad snapshot')
  244. for (const operation of this.operations) {
  245. try {
  246. operation.applyTo(snapshot, opts)
  247. } catch (err) {
  248. const recoverable =
  249. err instanceof Snapshot.EditMissingFileError ||
  250. err instanceof FileMap.FileNotFoundError
  251. if (!recoverable || opts.strict) {
  252. throw err
  253. }
  254. }
  255. yield operation
  256. }
  257. // update project version if present in change
  258. if (this.projectVersion) {
  259. snapshot.setProjectVersion(this.projectVersion)
  260. }
  261. // update doc versions
  262. if (this.v2DocVersions) {
  263. snapshot.updateV2DocVersions(this.v2DocVersions)
  264. }
  265. }
  266. /**
  267. * Transform this change to account for the fact that the other change occurred
  268. * simultaneously and was applied first.
  269. *
  270. * This change is modified in place (by transforming its operations).
  271. *
  272. * @param {Change} other
  273. */
  274. transformAfter(other) {
  275. assert.object(other, 'bad other')
  276. const thisOperations = this.getOperations()
  277. const otherOperations = other.getOperations()
  278. for (let i = 0; i < otherOperations.length; ++i) {
  279. for (let j = 0; j < thisOperations.length; ++j) {
  280. thisOperations[j] = Operation.transform(
  281. thisOperations[j],
  282. otherOperations[i]
  283. )[0]
  284. }
  285. }
  286. }
  287. clone() {
  288. return Change.fromRaw(this.toRaw())
  289. }
  290. async store(blobStore, concurrency) {
  291. assert.maybe.number(concurrency, 'bad concurrency')
  292. const raw = this.toRaw()
  293. raw.authors = _.uniq(raw.authors)
  294. const rawOperations = await pMap(
  295. this.operations,
  296. operation => operation.store(blobStore),
  297. { concurrency: concurrency || 1 }
  298. )
  299. raw.operations = rawOperations
  300. return raw
  301. }
  302. canBeComposedWith(other) {
  303. const operations = this.getOperations()
  304. const otherOperations = other.getOperations()
  305. // We ignore complex changes with more than 1 operation
  306. if (operations.length > 1 || otherOperations.length > 1) return false
  307. return operations[0].canBeComposedWith(otherOperations[0])
  308. }
  309. }
  310. module.exports = Change