change.js 8.9 KB

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