| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294 |
- import pLimit from 'p-limit'
- import OError from '@overleaf/o-error'
- import { Change, Chunk, Snapshot } from 'overleaf-editor-core'
- import { RawChange, RawChunk } from 'overleaf-editor-core/lib/types'
- import { FetchError, getJSON, postJSON } from '@/infrastructure/fetch-json'
- const DOWNLOAD_BLOBS_CONCURRENCY = 10
- /**
- * Project snapshot container with on-demand refresh
- */
- export class ProjectSnapshot {
- private projectId: string
- private snapshot: Snapshot
- private version: number
- private blobStore: SimpleBlobStore
- private refreshPromise: Promise<void>
- private queuedRefreshPromise: Promise<void>
- private state: ProjectSnapshotState
- constructor(projectId: string) {
- this.projectId = projectId
- this.snapshot = new Snapshot()
- this.version = 0
- this.refreshPromise = Promise.resolve()
- this.queuedRefreshPromise = Promise.resolve()
- this.state = new ProjectSnapshotState()
- this.blobStore = new SimpleBlobStore(this.projectId)
- }
- /**
- * Request a refresh of the snapshot.
- *
- * When the returned promise resolves, the snapshot is guaranteed to have been
- * updated at least to the version of the document that was current when the
- * function was called.
- */
- async refresh() {
- switch (this.state.getState()) {
- case 'init':
- this.refreshPromise = this.initialize()
- await this.refreshPromise
- break
- case 'ready':
- this.refreshPromise = this.loadChanges()
- await this.refreshPromise
- break
- case 'refreshing':
- this.queuedRefreshPromise = this.queueRefresh()
- await this.queuedRefreshPromise
- break
- case 'queued-ready':
- case 'queued-waiting':
- await this.queuedRefreshPromise
- break
- default:
- throw new OError('Unknown state for project snapshot', {
- state: this.state.getState(),
- })
- }
- }
- /**
- * Get the list of paths to editable docs.
- */
- getDocPaths(): string[] {
- const allPaths = this.snapshot.getFilePathnames()
- return allPaths.filter(path => this.snapshot.getFile(path)?.isEditable())
- }
- /**
- * Get the doc content at the given path.
- */
- getDocContents(path: string): string | null {
- const file = this.snapshot.getFile(path)
- if (file == null) {
- return null
- }
- return file.getContent({ filterTrackedDeletes: true }) ?? null
- }
- /**
- * Initialize the snapshot using the project's latest chunk.
- *
- * This is run on the first refresh.
- */
- private async initialize() {
- this.state.startRefresh()
- await flushHistory(this.projectId)
- const chunk = await fetchLatestChunk(this.projectId)
- this.snapshot = chunk.getSnapshot()
- this.snapshot.applyAll(chunk.getChanges())
- this.version = chunk.getEndVersion()
- await this.loadDocs()
- this.state.endRefresh()
- }
- /**
- * Apply changes since the last refresh.
- *
- * This is run on the second and subsequent refreshes
- */
- private async loadChanges() {
- this.state.startRefresh()
- await flushHistory(this.projectId)
- const changes = await fetchLatestChanges(this.projectId, this.version)
- this.snapshot.applyAll(changes)
- this.version += changes.length
- await this.loadDocs()
- this.state.endRefresh()
- }
- /**
- * Wait for the current refresh to complete, then start a refresh.
- */
- private async queueRefresh() {
- this.state.queueRefresh()
- await this.refreshPromise
- await this.loadChanges()
- }
- /**
- * Load all editable docs in the snapshot.
- *
- * This is done by converting any lazy file data into an "eager" file data. If
- * a doc is already loaded, the load is a no-op.
- */
- private async loadDocs() {
- const paths = this.getDocPaths()
- const limit = pLimit(DOWNLOAD_BLOBS_CONCURRENCY)
- await Promise.all(
- paths.map(path =>
- limit(async () => {
- const file = this.snapshot.getFile(path)
- await file?.load('eager', this.blobStore)
- })
- )
- )
- }
- }
- /**
- * State machine for the project snapshot
- *
- * There are 5 states:
- *
- * - init: when the snapshot is built
- * - refreshing: while the snapshot is refreshing
- * - queued-waiting: while the snapshot is refreshing and another refresh is queued
- * - queued-ready: when a refresh is queued, but no refresh is running
- * - ready: when no refresh is running and no refresh is queued
- *
- * There are three transitions:
- *
- * - start: start a refresh operation
- * - end: end a refresh operation
- * - queue: queue a refresh operation
- *
- * Valid transitions are as follows:
- *
- * +------------+
- * | ready |
- * +------------+
- * ^ |
- * | |
- * end start
- * | |
- * | v
- * +------+ +------------+ +----------------+
- * | init |----start---->| refreshing |---queue---> | queued-waiting |
- * +------+ +------------+ +----------------+
- * ^ |
- * | |
- * start end
- * | |
- * | +--------------+ |
- * +-----| queued-ready |<-------+
- * +--------------+
- *
- * These transitions ensure that there are never two refreshes running
- * concurrently. In every path, "start" and "end" transitions always alternate.
- * You never have two consecutive "start" or two consecutive "end".
- */
- class ProjectSnapshotState {
- private state:
- | 'init'
- | 'refreshing'
- | 'ready'
- | 'queued-waiting'
- | 'queued-ready' = 'init'
- getState() {
- return this.state
- }
- startRefresh() {
- switch (this.state) {
- case 'init':
- case 'ready':
- case 'queued-ready':
- this.state = 'refreshing'
- break
- default:
- throw new OError("Can't start a snapshot refresh in this state", {
- state: this.state,
- })
- }
- }
- endRefresh() {
- switch (this.state) {
- case 'refreshing':
- this.state = 'ready'
- break
- case 'queued-waiting':
- this.state = 'queued-ready'
- break
- default:
- throw new OError("Can't end a snapshot refresh in this state", {
- state: this.state,
- })
- }
- }
- queueRefresh() {
- switch (this.state) {
- case 'refreshing':
- this.state = 'queued-waiting'
- break
- default:
- throw new OError("Can't queue a snapshot refresh in this state", {
- state: this.state,
- })
- }
- }
- }
- /**
- * Blob store that fetches blobs from the history service
- */
- class SimpleBlobStore {
- private projectId: string
- constructor(projectId: string) {
- this.projectId = projectId
- }
- async getString(hash: string): Promise<string> {
- return await fetchBlob(this.projectId, hash)
- }
- async getObject(hash: string) {
- const blob = await this.getString(hash)
- return JSON.parse(blob)
- }
- }
- async function flushHistory(projectId: string) {
- await postJSON(`/project/${projectId}/flush`)
- }
- async function fetchLatestChunk(projectId: string): Promise<Chunk> {
- const response = await getJSON<{ chunk: RawChunk }>(
- `/project/${projectId}/latest/history`
- )
- return Chunk.fromRaw(response.chunk)
- }
- async function fetchLatestChanges(
- projectId: string,
- version: number
- ): Promise<Change[]> {
- const response = await getJSON<RawChange[]>(
- `/project/${projectId}/changes?since=${version}`
- )
- return response.map(Change.fromRaw).filter(change => change != null)
- }
- async function fetchBlob(projectId: string, hash: string): Promise<string> {
- const url = `/project/${projectId}/blob/${hash}`
- const res = await fetch(url)
- if (!res.ok) {
- throw new FetchError('Failed to fetch blob', url, undefined, res)
- }
- return await res.text()
- }
|