project-snapshot.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. import pLimit from 'p-limit'
  2. import { Change, Chunk, Snapshot } from 'overleaf-editor-core'
  3. import { RawChange, RawChunk } from 'overleaf-editor-core/lib/types'
  4. import { FetchError, getJSON, postJSON } from '@/infrastructure/fetch-json'
  5. const DOWNLOAD_BLOBS_CONCURRENCY = 10
  6. /**
  7. * Project snapshot container with on-demand refresh
  8. */
  9. export class ProjectSnapshot {
  10. private projectId: string
  11. private snapshot: Snapshot
  12. private version: number
  13. private blobStore: SimpleBlobStore
  14. private refreshPromise: Promise<void>
  15. private initialized: boolean
  16. private refreshing: boolean
  17. private queued: boolean
  18. constructor(projectId: string) {
  19. this.projectId = projectId
  20. this.snapshot = new Snapshot()
  21. this.version = 0
  22. this.refreshPromise = Promise.resolve()
  23. this.initialized = false
  24. this.refreshing = false
  25. this.queued = false
  26. this.blobStore = new SimpleBlobStore(this.projectId)
  27. }
  28. /**
  29. * Request a refresh of the snapshot.
  30. *
  31. * When the returned promise resolves, the snapshot is guaranteed to have been
  32. * updated at least to the version of the document that was current when the
  33. * function was called.
  34. */
  35. async refresh() {
  36. if (this.queued) {
  37. // There already is a queued refresh that will run after this call.
  38. // Just wait for it to complete.
  39. await this.refreshPromise
  40. } else if (this.refreshing) {
  41. // There is a refresh running, but no queued refresh. Queue a refresh
  42. // after this one and make it the new promise to wait for.
  43. this.refreshPromise = this.queueRefresh()
  44. await this.refreshPromise
  45. } else {
  46. // There is no refresh running. Start one.
  47. this.refreshPromise = this.startRefresh()
  48. await this.refreshPromise
  49. }
  50. }
  51. /**
  52. * Get the list of paths to editable docs.
  53. */
  54. getDocPaths(): string[] {
  55. const allPaths = this.snapshot.getFilePathnames()
  56. return allPaths.filter(path => this.snapshot.getFile(path)?.isEditable())
  57. }
  58. /**
  59. * Get the doc content at the given path.
  60. */
  61. getDocContents(path: string): string | null {
  62. const file = this.snapshot.getFile(path)
  63. if (file == null) {
  64. return null
  65. }
  66. return file.getContent({ filterTrackedDeletes: true }) ?? null
  67. }
  68. /**
  69. * Immediately start a refresh
  70. */
  71. private async startRefresh() {
  72. this.refreshing = true
  73. try {
  74. if (!this.initialized) {
  75. await this.initialize()
  76. } else {
  77. await this.loadChanges()
  78. }
  79. } finally {
  80. this.refreshing = false
  81. }
  82. }
  83. /**
  84. * Queue a refresh after the currently running refresh
  85. */
  86. private async queueRefresh() {
  87. this.queued = true
  88. try {
  89. await this.refreshPromise
  90. } catch {
  91. // Ignore errors
  92. }
  93. this.queued = false
  94. await this.startRefresh()
  95. }
  96. /**
  97. * Initialize the snapshot using the project's latest chunk.
  98. *
  99. * This is run on the first refresh.
  100. */
  101. private async initialize() {
  102. await flushHistory(this.projectId)
  103. const chunk = await fetchLatestChunk(this.projectId)
  104. this.snapshot = chunk.getSnapshot()
  105. this.snapshot.applyAll(chunk.getChanges())
  106. this.version = chunk.getEndVersion()
  107. await this.loadDocs()
  108. this.initialized = true
  109. }
  110. /**
  111. * Apply changes since the last refresh.
  112. *
  113. * This is run on the second and subsequent refreshes
  114. */
  115. private async loadChanges() {
  116. await flushHistory(this.projectId)
  117. let hasMore = true
  118. while (hasMore) {
  119. const response = await fetchLatestChanges(this.projectId, this.version)
  120. const changes = response.changes
  121. this.snapshot.applyAll(changes)
  122. this.version += changes.length
  123. hasMore = response.hasMore
  124. }
  125. await this.loadDocs()
  126. }
  127. /**
  128. * Load all editable docs in the snapshot.
  129. *
  130. * This is done by converting any lazy file data into an "eager" file data. If
  131. * a doc is already loaded, the load is a no-op.
  132. */
  133. private async loadDocs() {
  134. const paths = this.getDocPaths()
  135. const limit = pLimit(DOWNLOAD_BLOBS_CONCURRENCY)
  136. await Promise.all(
  137. paths.map(path =>
  138. limit(async () => {
  139. const file = this.snapshot.getFile(path)
  140. await file?.load('eager', this.blobStore)
  141. })
  142. )
  143. )
  144. }
  145. }
  146. /**
  147. * Blob store that fetches blobs from the history service
  148. */
  149. class SimpleBlobStore {
  150. private projectId: string
  151. constructor(projectId: string) {
  152. this.projectId = projectId
  153. }
  154. async getString(hash: string): Promise<string> {
  155. return await fetchBlob(this.projectId, hash)
  156. }
  157. async getObject(hash: string) {
  158. const blob = await this.getString(hash)
  159. return JSON.parse(blob)
  160. }
  161. }
  162. async function flushHistory(projectId: string) {
  163. await postJSON(`/project/${projectId}/flush`)
  164. }
  165. async function fetchLatestChunk(projectId: string): Promise<Chunk> {
  166. const response = await getJSON<{ chunk: RawChunk }>(
  167. `/project/${projectId}/latest/history`
  168. )
  169. return Chunk.fromRaw(response.chunk)
  170. }
  171. type FetchLatestChangesResponse = {
  172. changes: Change[]
  173. hasMore: boolean
  174. }
  175. type FetchLatestChangesApiResponse =
  176. | RawChange[]
  177. | {
  178. changes: RawChange[]
  179. hasMore: boolean
  180. }
  181. async function fetchLatestChanges(
  182. projectId: string,
  183. version: number
  184. ): Promise<FetchLatestChangesResponse> {
  185. // TODO: The paginated flag is a transition flag. It can be removed after this
  186. // code has been deployed for a few weeks.
  187. const response = await getJSON<FetchLatestChangesApiResponse>(
  188. `/project/${projectId}/changes?since=${version}&paginated=true`
  189. )
  190. let changes, hasMore
  191. if (Array.isArray(response)) {
  192. // deprecated response format is a simple array of changes
  193. // TODO: Remove this branch after the transition
  194. changes = response
  195. hasMore = false
  196. } else {
  197. changes = response.changes
  198. hasMore = response.hasMore
  199. }
  200. return {
  201. changes: changes.map(Change.fromRaw).filter(change => change != null),
  202. hasMore,
  203. }
  204. }
  205. async function fetchBlob(projectId: string, hash: string): Promise<string> {
  206. const url = `/project/${projectId}/blob/${hash}`
  207. const res = await fetch(url)
  208. if (!res.ok) {
  209. throw new FetchError('Failed to fetch blob', url, undefined, res)
  210. }
  211. return await res.text()
  212. }