SnapshotManager.js 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. // @ts-check
  2. import { callbackify } from 'node:util'
  3. import Core from 'overleaf-editor-core'
  4. import { Readable as StringStream } from 'node:stream'
  5. import OError from '@overleaf/o-error'
  6. import * as HistoryStoreManager from './HistoryStoreManager.js'
  7. import * as WebApiManager from './WebApiManager.js'
  8. import * as Errors from './Errors.js'
  9. import _ from 'lodash'
  10. /**
  11. * @import { Snapshot } from 'overleaf-editor-core'
  12. * @import { RangesSnapshot } from './types'
  13. */
  14. StringStream.prototype._read = function () {}
  15. const MAX_REQUESTS = 4 // maximum number of parallel requests to v1 history service
  16. /**
  17. *
  18. * @param {string} projectId
  19. * @param {number} version
  20. * @param {string} pathname
  21. */
  22. async function getFileSnapshotStream(projectId, version, pathname) {
  23. const snapshot = await _getSnapshotAtVersion(projectId, version)
  24. const file = snapshot.getFile(pathname)
  25. if (file == null) {
  26. throw new Errors.NotFoundError(`${pathname} not found`, {
  27. projectId,
  28. version,
  29. pathname,
  30. })
  31. }
  32. const historyId = await WebApiManager.promises.getHistoryId(projectId)
  33. if (file.isEditable()) {
  34. await file.load('eager', HistoryStoreManager.getBlobStore(historyId))
  35. const stream = new StringStream()
  36. stream.push(file.getContent({ filterTrackedDeletes: true }))
  37. stream.push(null)
  38. return stream
  39. } else {
  40. return await HistoryStoreManager.promises.getProjectBlobStream(
  41. historyId,
  42. file.getHash()
  43. )
  44. }
  45. }
  46. /**
  47. * Constructs a snapshot of the ranges in a document-updater compatible format.
  48. * Positions will be relative to a document where tracked deletes have been
  49. * removed from the string. This also means that if a tracked delete overlaps
  50. * a comment range, the comment range will be truncated.
  51. *
  52. * @param {string} projectId
  53. * @param {number} version
  54. * @param {string} pathname
  55. * @returns {Promise<RangesSnapshot>}
  56. */
  57. async function getRangesSnapshot(projectId, version, pathname) {
  58. const snapshot = await _getSnapshotAtVersion(projectId, version)
  59. const file = snapshot.getFile(pathname)
  60. if (!file) {
  61. throw new Errors.NotFoundError(`${pathname} not found`, {
  62. projectId,
  63. version,
  64. pathname,
  65. })
  66. }
  67. if (!file.isEditable()) {
  68. // A binary file has no tracked changes or comments
  69. return {
  70. changes: [],
  71. comments: [],
  72. }
  73. }
  74. const historyId = await WebApiManager.promises.getHistoryId(projectId)
  75. await file.load('eager', HistoryStoreManager.getBlobStore(historyId))
  76. // Use the utility function from overleaf-editor-core
  77. const { changes, comments } = Core.getDocUpdaterCompatibleRanges(file)
  78. return { changes, comments }
  79. }
  80. /**
  81. * Gets the file metadata at a specific version.
  82. *
  83. * @param {string} projectId
  84. * @param {number} version
  85. * @param {string} pathname
  86. * @returns {Promise<{metadata: any}>}
  87. */
  88. async function getFileMetadataSnapshot(projectId, version, pathname) {
  89. const snapshot = await _getSnapshotAtVersion(projectId, version)
  90. const file = snapshot.getFile(pathname)
  91. if (!file) {
  92. throw new Errors.NotFoundError(`${pathname} not found`, {
  93. projectId,
  94. version,
  95. pathname,
  96. })
  97. }
  98. const rawMetadata = file.getMetadata()
  99. const metadata = _.isEmpty(rawMetadata) ? undefined : rawMetadata
  100. return { metadata }
  101. }
  102. // Returns project snapshot containing the document content for files with
  103. // text operations in the relevant chunk, and hashes for unmodified/binary
  104. // files. Used by git bridge to get the state of the project.
  105. async function getProjectSnapshot(projectId, version) {
  106. const snapshot = await _getSnapshotAtVersion(projectId, version)
  107. const historyId = await WebApiManager.promises.getHistoryId(projectId)
  108. await _loadFilesLimit(
  109. snapshot,
  110. 'eager',
  111. HistoryStoreManager.getBlobStore(historyId)
  112. )
  113. return {
  114. projectId,
  115. files: snapshot.getFileMap().map(file => {
  116. if (!file) {
  117. return null
  118. }
  119. const content = file.getContent({
  120. filterTrackedDeletes: true,
  121. })
  122. if (content === null) {
  123. return { data: { hash: file.getHash() } }
  124. }
  125. return { data: { content } }
  126. }),
  127. }
  128. }
  129. async function getPathsAtVersion(projectId, version) {
  130. const snapshot = await _getSnapshotAtVersion(projectId, version)
  131. return {
  132. paths: snapshot.getFilePathnames(),
  133. }
  134. }
  135. /**
  136. *
  137. * @param {string} projectId
  138. * @param {number} version
  139. */
  140. async function _getSnapshotAtVersion(projectId, version) {
  141. const historyId = await WebApiManager.promises.getHistoryId(projectId)
  142. const data = await HistoryStoreManager.promises.getChunkAtVersion(
  143. projectId,
  144. historyId,
  145. version
  146. )
  147. const chunk = Core.Chunk.fromRaw(data.chunk)
  148. const snapshot = chunk.getSnapshot()
  149. const changes = chunk.getChanges().slice(0, version - chunk.getStartVersion())
  150. snapshot.applyAll(changes)
  151. return snapshot
  152. }
  153. /**
  154. * @param {string} projectId
  155. * @param {string} historyId
  156. * @return {Promise<Record<string, import('overleaf-editor-core').File>>}
  157. */
  158. async function getLatestSnapshotFiles(projectId, historyId) {
  159. const data = await HistoryStoreManager.promises.getMostRecentChunk(
  160. projectId,
  161. historyId
  162. )
  163. return await getLatestSnapshotFilesForChunk(historyId, data)
  164. }
  165. /**
  166. * @param {string} historyId
  167. * @param {{chunk: import('overleaf-editor-core/lib/types.js').RawChunk}} chunk
  168. * @return {Promise<Record<string, import('overleaf-editor-core').File>>}
  169. */
  170. async function getLatestSnapshotFilesForChunk(historyId, chunk) {
  171. const { snapshot } = getLatestSnapshotFromChunk(chunk)
  172. const snapshotFiles = await snapshot.loadFiles(
  173. 'lazy',
  174. HistoryStoreManager.getBlobStore(historyId)
  175. )
  176. return snapshotFiles
  177. }
  178. /**
  179. * @param {string} projectId
  180. * @param {string} historyId
  181. * @return {Promise<{version: number, snapshot: import('overleaf-editor-core').Snapshot}>}
  182. */
  183. async function getLatestSnapshot(projectId, historyId) {
  184. const data = await HistoryStoreManager.promises.getMostRecentChunk(
  185. projectId,
  186. historyId
  187. )
  188. return getLatestSnapshotFromChunk(data)
  189. }
  190. /**
  191. * @param {{chunk: import('overleaf-editor-core/lib/types.js').RawChunk}} data
  192. * @return {{version: number, snapshot: import('overleaf-editor-core').Snapshot}}
  193. */
  194. function getLatestSnapshotFromChunk(data) {
  195. if (data == null || data.chunk == null) {
  196. throw new OError('undefined chunk')
  197. }
  198. // apply all the changes in the chunk to get the current snapshot
  199. const chunk = Core.Chunk.fromRaw(data.chunk)
  200. const snapshot = chunk.getSnapshot()
  201. const changes = chunk.getChanges()
  202. snapshot.applyAll(changes)
  203. return {
  204. snapshot,
  205. version: chunk.getEndVersion(),
  206. }
  207. }
  208. async function getChangesInChunkSince(projectId, historyId, sinceVersion) {
  209. const latestChunk = Core.Chunk.fromRaw(
  210. (
  211. await HistoryStoreManager.promises.getMostRecentChunk(
  212. projectId,
  213. historyId
  214. )
  215. ).chunk
  216. )
  217. if (sinceVersion > latestChunk.getEndVersion()) {
  218. throw new Errors.BadRequestError(
  219. 'requested version past the end of the history'
  220. )
  221. }
  222. const latestStartVersion = latestChunk.getStartVersion()
  223. let chunk = latestChunk
  224. if (sinceVersion < latestStartVersion) {
  225. chunk = Core.Chunk.fromRaw(
  226. (
  227. await HistoryStoreManager.promises.getChunkAtVersion(
  228. projectId,
  229. historyId,
  230. sinceVersion
  231. )
  232. ).chunk
  233. )
  234. }
  235. const changes = chunk
  236. .getChanges()
  237. .slice(sinceVersion - chunk.getStartVersion())
  238. return { latestStartVersion, changes }
  239. }
  240. async function _loadFilesLimit(snapshot, kind, blobStore) {
  241. await snapshot.fileMap.mapAsync(async file => {
  242. // only load changed files or files with tracked changes, others can be
  243. // dereferenced from their blobs (this method is only used by the git
  244. // bridge which understands how to load blobs).
  245. if (!file.isEditable() || (file.getHash() && !file.getRangesHash())) {
  246. return
  247. }
  248. await file.load(kind, blobStore)
  249. }, MAX_REQUESTS)
  250. }
  251. // EXPORTS
  252. const getChangesInChunkSinceCb = callbackify(getChangesInChunkSince)
  253. const getFileSnapshotStreamCb = callbackify(getFileSnapshotStream)
  254. const getProjectSnapshotCb = callbackify(getProjectSnapshot)
  255. const getLatestSnapshotCb = callbackify(getLatestSnapshot)
  256. const getLatestSnapshotFilesCb = callbackify(getLatestSnapshotFiles)
  257. const getLatestSnapshotFilesForChunkCb = callbackify(
  258. getLatestSnapshotFilesForChunk
  259. )
  260. const getRangesSnapshotCb = callbackify(getRangesSnapshot)
  261. const getFileMetadataSnapshotCb = callbackify(getFileMetadataSnapshot)
  262. const getPathsAtVersionCb = callbackify(getPathsAtVersion)
  263. export {
  264. getLatestSnapshotFromChunk,
  265. getChangesInChunkSinceCb as getChangesInChunkSince,
  266. getFileSnapshotStreamCb as getFileSnapshotStream,
  267. getProjectSnapshotCb as getProjectSnapshot,
  268. getFileMetadataSnapshotCb as getFileMetadataSnapshot,
  269. getLatestSnapshotCb as getLatestSnapshot,
  270. getLatestSnapshotFilesCb as getLatestSnapshotFiles,
  271. getLatestSnapshotFilesForChunkCb as getLatestSnapshotFilesForChunk,
  272. getRangesSnapshotCb as getRangesSnapshot,
  273. getPathsAtVersionCb as getPathsAtVersion,
  274. }
  275. export const promises = {
  276. getChangesInChunkSince,
  277. getFileSnapshotStream,
  278. getProjectSnapshot,
  279. getLatestSnapshot,
  280. getLatestSnapshotFiles,
  281. getLatestSnapshotFilesForChunk,
  282. getRangesSnapshot,
  283. getPathsAtVersion,
  284. getFileMetadataSnapshot,
  285. }