Files
overleaf-cep/services/web/frontend/js/infrastructure/project-snapshot.ts
T
Jakob AckermannandClaude Opus 5 6caa5fc3a3 [monorepo] read empty content from its hash, in a shared blob store base (#36831)
The hash of empty content is a property of the content rather than of anything
stored: every project's empty file has it, whether or not a blob was ever written
under it, and a file may reference it before anything has. Eight blob stores were
each paying a lookup -- a database query, an HTTP request, a read of a cache that
offline mode may be the only thing standing in for -- to be told what the hash
already says, and clsi had already reinvented the answer on its own.

overleaf-editor-core gains BlobStoreBase, which answers getString for that hash
and delegates the rest to the fetchString its subclasses provide. getObject comes
with it: four stores spelled out the same JSON.parse of getString, and a fifth
wrapped it in a log line.

Adopted by history-v1's BlobStore, BackupBlobStore, HashCheckBlobStore and
recover_zip's RecoveryBlobStore, by project-history's BlobStore, by clsi's
BlobStore, and by web's HistoryBlobStore, SimpleBlobStore and FrontendBlobStore.
history-v1 keeps its own answers in getStream, getBlob and getBlobs, which no
other store implements. Writing and copying a blob are untouched, so a project
that wrote empty content still has a blob for it and a read answers the same
either way.

Two paths could not handle a project holding a tracked change or a comment at
all. Loading such a file needs the ranges blob its rangesHash points at, and
hashing one again needs to write it back, so a store missing getObject or
putObject fails outright:

- recover_zip died on `blobStore.getObject is not a function`, making every such
  project unrecoverable. The test covering it asserts a tracked delete is absent
  from the zip, which the script already asked for and never reached.
- GET /projects/:project_id/latest/hashed_content returned 500 from the same
  cause, then would have hit a missing putObject in snapshot.store.
  HashCheckBlobStore becomes a class over the base and gains putObject.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
GitOrigin-RevId: c84f62465beff6cf77ffac26aa772984e1b554cb
2026-09-17 12:06:30 +00:00

364 lines
9.7 KiB
TypeScript

import pLimit from 'p-limit'
import {
BlobStoreBase,
Change,
Chunk,
Snapshot,
File,
} from 'overleaf-editor-core'
import type { RawChange, RawChunk } from 'overleaf-editor-core/lib/types'
import { FetchError, getJSON, postJSON } from '@/infrastructure/fetch-json'
import path from 'path-browserify'
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 initialized: boolean
private refreshing: boolean
private queued: boolean
constructor(projectId: string) {
this.projectId = projectId
this.snapshot = new Snapshot()
this.version = 0
this.refreshPromise = Promise.resolve()
this.initialized = false
this.refreshing = false
this.queued = false
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() {
if (this.queued) {
// There already is a queued refresh that will run after this call.
// Just wait for it to complete.
await this.refreshPromise
} else if (this.refreshing) {
// There is a refresh running, but no queued refresh. Queue a refresh
// after this one and make it the new promise to wait for.
this.refreshPromise = this.queueRefresh()
await this.refreshPromise
} else {
// There is no refresh running. Start one.
this.refreshPromise = this.startRefresh()
await this.refreshPromise
}
}
/**
* 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 list of paths to binary files.
*/
getBinaryFilePathsWithHash(): { path: string; hash: string; size: number }[] {
const allPaths = this.snapshot.getFilePathnames()
const paths = []
for (const path of allPaths) {
const file = this.snapshot.getFile(path)
if (file == null || file.isEditable()) {
continue
}
const hash = file.getHash()
const size = file.getByteLength()
if (hash == null) {
continue
}
if (size == null) {
continue
}
paths.push({ path, hash, size })
}
return paths
}
/**
* Use an algorithm similar to Kpathsea to locate files in the project snapshot:
*
* 1. look for the exact path relative to the root path
* 2. look for the path + extension relative to the root path
* 3. look for the exact path relative to the current path
* 4. look for the path + extension relative to the current path
*/
locateFile(filePath: string, currentPath = '/', extensions = ['.tex']) {
// ignore absolute paths
if (filePath.startsWith('/')) {
return null
}
const snapshotPaths = new Set(this.snapshot.getFilePathnames())
const basePaths = [
// relative to the root of the compile directory
'/',
]
if (currentPath !== '/') {
// relative to the current directory
basePaths.push(currentPath)
}
const extensionsToTest = ['', ...extensions]
for (const basePath of basePaths) {
for (const extension of extensionsToTest) {
const pathname = path.resolve(basePath, `${filePath}${extension}`)
const snapshotPath = pathname.substring(1) // remove leading slash
if (snapshotPaths.has(snapshotPath)) {
return snapshotPath
}
}
}
return null
}
/**
* 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
}
async getBinaryFileContents(
path: string,
options?: { maxSize?: number }
): Promise<any> {
const file = this.snapshot.getFile(path)
const hash = file?.getHash()
const byteLength = file?.getByteLength()
if (hash == null) {
return null
}
if (byteLength == null) {
return null
}
if (options?.maxSize != null && byteLength > options?.maxSize) {
const blobStoreOptions = { maxSize: options.maxSize }
// Straight to fetchString: a binary file large enough to be read with a size
// limit is never the empty content the base class answers for.
return await this.blobStore.fetchString(hash, blobStoreOptions)
}
return await this.blobStore.getString(hash)
}
getDocs(): Map<string, File> {
const files = new Map()
for (const path of this.snapshot.getFilePathnames()) {
const file = this.snapshot.getFile(path)
if (file?.isEditable()) {
files.set(path, file)
}
}
return files
}
/**
* Immediately start a refresh
*/
private async startRefresh() {
this.refreshing = true
try {
if (!this.initialized) {
await this.initialize()
} else {
await this.loadChanges()
}
} finally {
this.refreshing = false
}
}
/**
* Queue a refresh after the currently running refresh
*/
private async queueRefresh() {
this.queued = true
try {
await this.refreshPromise
} catch {
// Ignore errors
}
this.queued = false
await this.startRefresh()
}
/**
* Initialize the snapshot using the project's latest chunk.
*
* This is run on the first refresh.
*/
private async initialize() {
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.initialized = true
}
/**
* Apply changes since the last refresh.
*
* This is run on the second and subsequent refreshes
*/
private async loadChanges() {
await flushHistory(this.projectId)
let hasMore = true
while (hasMore) {
const response = await fetchLatestChanges(this.projectId, this.version)
const changes = response.changes
this.snapshot.applyAll(changes)
this.version += changes.length
hasMore = response.hasMore
}
await this.loadDocs()
}
/**
* 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)
})
)
)
}
}
/**
* Blob store that fetches blobs from the history service
*/
export class SimpleBlobStore extends BlobStoreBase {
private projectId: string
constructor(projectId: string) {
super()
this.projectId = projectId
}
async fetchString(
hash: string,
options?: { maxSize?: number }
): Promise<string> {
return await fetchBlob(this.projectId, hash, options)
}
}
async function flushHistory(projectId: string) {
await postJSON(`/project/${projectId}/flush`)
}
export async function fetchLatestChunk(projectId: string): Promise<Chunk> {
const response = await getJSON<{ chunk: RawChunk }>(
`/project/${projectId}/latest/history`
)
return Chunk.fromRaw(response.chunk)
}
type FetchLatestChangesResponse = {
changes: Change[]
hasMore: boolean
}
type FetchLatestChangesApiResponse =
| RawChange[]
| {
changes: RawChange[]
hasMore: boolean
}
async function fetchLatestChanges(
projectId: string,
version: number
): Promise<FetchLatestChangesResponse> {
// TODO: The paginated flag is a transition flag. It can be removed after this
// code has been deployed for a few weeks.
const response = await getJSON<FetchLatestChangesApiResponse>(
`/project/${projectId}/changes?since=${version}&paginated=true`
)
let changes, hasMore
if (Array.isArray(response)) {
// deprecated response format is a simple array of changes
// TODO: Remove this branch after the transition
changes = response
hasMore = false
} else {
changes = response.changes
hasMore = response.hasMore
}
return {
changes: changes.map(Change.fromRaw).filter(change => change != null),
hasMore,
}
}
async function fetchBlob(
projectId: string,
hash: string,
options?: { maxSize?: number }
): Promise<string> {
const url = `/project/${projectId}/blob/${hash}`
let fetchOpts
if (options?.maxSize === 0) {
return ''
}
if (options?.maxSize) {
fetchOpts = {
headers: {
Range: `bytes=0-${options.maxSize - 1}`,
},
}
}
const res = await fetch(url, fetchOpts)
if (!res.ok) {
throw new FetchError('Failed to fetch blob', url, undefined, res)
}
// Use arrayBuffer + TextDecoder rather than res.text() to preserve any
// UTF-8 BOM (U+FEFF) in the blob content. The server stores blobs as-is
// and includes the BOM in stringLength, so text operations are built
// against a BOM-inclusive length. Response.text() strips the BOM per the
// Encoding spec, making the string 1 char shorter than expected and causing
// ApplyError when the operations are applied.
const buffer = await res.arrayBuffer()
return new TextDecoder('utf-8', { ignoreBOM: true }).decode(buffer)
}