mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
* [editor-core] hold the one threshold that decides which store owns the file tree HISTORY_FILE_TREE_STAGE is five literals across the services that consume this library -- web backend, web editor, real-time, third-party-datastore, github-sync -- linked by comments saying which other copies they are aligned with. What the docstring here says is what goes wrong: this is the single thing that decides which storage is authoritative, a project crosses it once with no way back, and a copy that drifts sends a whole service's writes to the store nothing reads any more. Silently, and for good. overleaf-editor-core is where it goes: every one of the five already depends on it, the editor included, and the library that defines what a file tree is can say where one lives. The consumers adopt it separately; this is the definition they adopt. historyIsSourceOfTruth goes with it, and refuses a stage that is neither absent nor a number rather than reading it as low -- web's copy coerced, which turned a corrupt stage into a permanent mongo write. Absent stays "below the gate": a project that never migrated has no stage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [editor-core] hold which doc a project opens on History records it as `main: true` on the file, and nothing derives it: every writer that creates a project has to record it, or the project compiles nothing until the user picks a file. So the rule for choosing one, and the operations that record it, go where a file tree is defined rather than with any one writer. The rule is the one web has always applied, over a snapshot instead of a mongo tree: of the docs that declare a document class, the one nearest the project root, alphabetically among equals, so that the same files always give the same answer. A project that already records a root doc keeps it -- that is a user's choice and not something to re-derive. Whether a doc could be the one is asked of content rather than of a store to read it from: a writer creating a project has each doc's content in its hands as it stores it, so reading them back would be a second read of every one. The choice is then made from the pathnames alone. The extension list joins the others in text_file_defaults for the reason they are there: a writer holding a shorter copy leaves a project with nothing to compile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [editor-core] say why a store built for a consumer does not need getBlob ReadWriteBlobStore is BlobStore without the lookup by hash, and the type said so without saying why a consumer can do without it. getBlob is reached only for a file stored as a bare hash, and one of those does not leave history-v1: chunk_store's lazyLoadHistoryFiles attaches blob metadata to every snapshot and change it serves, so a file arrives already knowing its own lengths. That is what the contract rests on, and a store that reimplemented the classification rule to answer the lookup anyway would be a second opinion on whether a file is editable. The reasoning was what was missing, rather than the type. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [editor-core] say what makes a doc a root doc candidate The sentence naming the two halves of the check had lost the first one, and read "named as a root doc may be named". It is the extension that is tested: one of DEFAULT_ROOT_DOC_EXTENSIONS, and a document class declared in the content. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [editor-core] hold the doc flags a file's metadata can carry in one place `main` is not the only flag a doc carries -- `mainBibliography` marks the file references are added to -- and both live in the same metadata, which SetFileMetadataOperation replaces wholesale rather than merges. So every writer that sets one has to write the others out again, and every reader that asks whether a file is a doc has to know the whole list. root_doc was spelling all three of those for itself against `main` alone: it tested the key by hand, refused a file whose metadata held anything else, and cleared the metadata to `{}` when moving the root doc elsewhere. Against a file that is also the main bibliography, the first two refuse a legitimate root doc and the third silently drops the bibliography. The helpers answer it once: which keys are doc flags, whether metadata holds only those, and what metadata with one flag changed looks like. root_doc uses them throughout, and the schema takes both flags on one file. Co-Authored-By: Mathias Jakobsen <mathias.jakobsen@overleaf.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [editor-core] ask the snapshot which doc carries a flag Finding the file a doc flag is set on is the same search whichever flag is being asked about, and it belongs to the snapshot rather than to the caller: it walks the snapshot's own pathnames, and it is the snapshot that knows a file's metadata without its content being loaded. root_doc's mainPathname is that search for `main`, and now delegates. It gains the check it was missing: a file whose metadata holds anything besides doc flags came from outside the editor, so a stale `main` on it does not make it the root doc -- which is already what setMainPathnameOperations refuses to write. Co-Authored-By: Mathias Jakobsen <mathias.jakobsen@overleaf.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [editor-core] drop mainPathname for the snapshot method behind it It became a second name for `snapshot.getPathnameWithDocFlag('main')` and nothing in the library was left calling it but chooseRootDoc, which asks the snapshot directly now. Its two tests go with it: Snapshot's own cover the same two answers, and more of them. Callers outside the library ask the snapshot too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Mathias Jakobsen <mathias.jakobsen@overleaf.com> GitOrigin-RevId: b3f503aa3e94ea8b6524d83f910fe33dc381c0a5
334 lines
7.8 KiB
JavaScript
334 lines
7.8 KiB
JavaScript
// @ts-check
|
|
'use strict'
|
|
|
|
const assert = require('check-types').assert
|
|
const OError = require('@overleaf/o-error')
|
|
|
|
const FileMap = require('./file_map')
|
|
const V2DocVersions = require('./v2_doc_versions')
|
|
const {
|
|
hasDocumentMetadataFlag,
|
|
isDocumentMetadata,
|
|
} = require('./file_metadata')
|
|
|
|
/** @import { DOCUMENT_METADATA_KEYS } from "./file_metadata" */
|
|
|
|
const FILE_LOAD_CONCURRENCY = 50
|
|
|
|
/**
|
|
* @import { BlobStore, RawSnapshot, ReadonlyBlobStore } from "./types"
|
|
* @import Change from "./change"
|
|
* @import TextOperation from "./operation/text_operation"
|
|
* @import File from "./file"
|
|
*/
|
|
|
|
class EditMissingFileError extends OError {}
|
|
|
|
/**
|
|
* A Snapshot represents the state of a {@link Project} at a
|
|
* particular version.
|
|
*/
|
|
class Snapshot {
|
|
static PROJECT_VERSION_RX_STRING = '^[0-9]+\\.[0-9]+$'
|
|
static PROJECT_VERSION_RX = new RegExp(Snapshot.PROJECT_VERSION_RX_STRING)
|
|
static EditMissingFileError = EditMissingFileError
|
|
|
|
/**
|
|
* @param {RawSnapshot} raw
|
|
* @return {Snapshot}
|
|
*/
|
|
static fromRaw(raw) {
|
|
assert.object(raw.files, 'bad raw.files')
|
|
return new Snapshot(
|
|
FileMap.fromRaw(raw.files),
|
|
raw.projectVersion,
|
|
V2DocVersions.fromRaw(raw.v2DocVersions),
|
|
raw.timestamp ? new Date(raw.timestamp) : undefined
|
|
)
|
|
}
|
|
|
|
toRaw() {
|
|
/** @type RawSnapshot */
|
|
const raw = {
|
|
files: this.fileMap.toRaw(),
|
|
}
|
|
if (this.projectVersion) {
|
|
raw.projectVersion = this.projectVersion
|
|
}
|
|
if (this.v2DocVersions) {
|
|
raw.v2DocVersions = this.v2DocVersions.toRaw()
|
|
}
|
|
if (this.timestamp != null) {
|
|
raw.timestamp = this.timestamp.toISOString()
|
|
}
|
|
return raw
|
|
}
|
|
|
|
/**
|
|
* @param {FileMap} [fileMap]
|
|
* @param {string} [projectVersion]
|
|
* @param {V2DocVersions} [v2DocVersions]
|
|
* @param {Date} [timestamp]
|
|
*/
|
|
constructor(fileMap, projectVersion, v2DocVersions, timestamp) {
|
|
assert.maybe.instance(fileMap, FileMap, 'bad fileMap')
|
|
|
|
this.fileMap = fileMap || new FileMap({})
|
|
this.projectVersion = projectVersion
|
|
this.v2DocVersions = v2DocVersions
|
|
this.timestamp = timestamp ?? null
|
|
}
|
|
|
|
/**
|
|
* @return {string | null | undefined}
|
|
*/
|
|
getProjectVersion() {
|
|
return this.projectVersion
|
|
}
|
|
|
|
/**
|
|
* @param {string} projectVersion
|
|
*/
|
|
setProjectVersion(projectVersion) {
|
|
assert.maybe.match(
|
|
projectVersion,
|
|
Snapshot.PROJECT_VERSION_RX,
|
|
'Snapshot: bad projectVersion'
|
|
)
|
|
this.projectVersion = projectVersion
|
|
}
|
|
|
|
/**
|
|
* @return {V2DocVersions | null | undefined}
|
|
*/
|
|
getV2DocVersions() {
|
|
return this.v2DocVersions
|
|
}
|
|
|
|
/**
|
|
* @param {V2DocVersions} v2DocVersions
|
|
*/
|
|
setV2DocVersions(v2DocVersions) {
|
|
assert.maybe.instance(
|
|
v2DocVersions,
|
|
V2DocVersions,
|
|
'Snapshot: bad v2DocVersions'
|
|
)
|
|
this.v2DocVersions = v2DocVersions
|
|
}
|
|
|
|
/**
|
|
* @param {V2DocVersions} v2DocVersions
|
|
*/
|
|
updateV2DocVersions(v2DocVersions) {
|
|
// merge new v2DocVersions into this.v2DocVersions
|
|
v2DocVersions.applyTo(this)
|
|
}
|
|
|
|
getTimestamp() {
|
|
return this.timestamp
|
|
}
|
|
|
|
/**
|
|
* @param {Date} timestamp
|
|
*/
|
|
setTimestamp(timestamp) {
|
|
this.timestamp = timestamp
|
|
}
|
|
|
|
/**
|
|
* The underlying file map.
|
|
* @return {FileMap}
|
|
*/
|
|
getFileMap() {
|
|
return this.fileMap
|
|
}
|
|
|
|
/**
|
|
* The pathnames of all of the files.
|
|
*
|
|
* @return {Array.<string>} in no particular order
|
|
*/
|
|
getFilePathnames() {
|
|
return this.fileMap.getPathnames()
|
|
}
|
|
|
|
/**
|
|
* Get a File by its pathname.
|
|
* @see FileMap#getFile
|
|
* @param {string} pathname
|
|
*/
|
|
getFile(pathname) {
|
|
return this.fileMap.getFile(pathname)
|
|
}
|
|
|
|
/**
|
|
* The pathname of the doc carrying a document flag (`main`, `mainBibliography`),
|
|
* if this snapshot records one.
|
|
*
|
|
* Sorted, so that a project which somehow has two answers gives the same one
|
|
* every time it is asked.
|
|
*
|
|
* @param {typeof DOCUMENT_METADATA_KEYS[number]} key
|
|
* @return {string | undefined}
|
|
*/
|
|
getPathnameWithDocFlag(key) {
|
|
return this.getFilePathnames()
|
|
.sort()
|
|
.find(pathname => {
|
|
const metadata = this.getFile(pathname)?.getMetadata()
|
|
return (
|
|
hasDocumentMetadataFlag(metadata, key) && isDocumentMetadata(metadata)
|
|
)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Add the given file to the snapshot.
|
|
* @see FileMap#addFile
|
|
* @param {string} pathname
|
|
* @param {File} file
|
|
*/
|
|
addFile(pathname, file) {
|
|
this.fileMap.addFile(pathname, file)
|
|
}
|
|
|
|
/**
|
|
* Move or remove a file.
|
|
* @see FileMap#moveFile
|
|
* @param {string} pathname
|
|
* @param {string} newPathname
|
|
*/
|
|
moveFile(pathname, newPathname) {
|
|
this.fileMap.moveFile(pathname, newPathname)
|
|
if (this.v2DocVersions) this.v2DocVersions.moveFile(pathname, newPathname)
|
|
}
|
|
|
|
/**
|
|
* The number of files in the snapshot.
|
|
*
|
|
* @return {number}
|
|
*/
|
|
countFiles() {
|
|
return this.fileMap.countFiles()
|
|
}
|
|
|
|
/**
|
|
* Edit the content of an editable file.
|
|
*
|
|
* Throws an error if no file with the given name exists.
|
|
*
|
|
* @param {string} pathname
|
|
* @param {TextOperation} textOperation
|
|
*/
|
|
editFile(pathname, textOperation) {
|
|
const file = this.fileMap.getFile(pathname)
|
|
if (!file) {
|
|
throw new Snapshot.EditMissingFileError(
|
|
`can't find file for editing: ${pathname}`
|
|
)
|
|
}
|
|
file.edit(textOperation)
|
|
}
|
|
|
|
/**
|
|
* Apply all changes in sequence. Modifies the snapshot in place.
|
|
*
|
|
* Ignore recoverable errors (caused by historical bad data) unless opts.strict is true
|
|
*
|
|
* @param {Change[]} changes
|
|
* @param {object} [opts]
|
|
* @param {boolean} opts.strict - do not ignore recoverable errors
|
|
*/
|
|
applyAll(changes, opts) {
|
|
for (const change of changes) {
|
|
change.applyTo(this, opts)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* If the Files in this Snapshot reference blob hashes, add them to the given
|
|
* set.
|
|
*
|
|
* @param {Set.<String>} blobHashes
|
|
*/
|
|
findBlobHashes(blobHashes) {
|
|
/**
|
|
* @param {File} file
|
|
*/
|
|
function find(file) {
|
|
const hash = file.getHash()
|
|
const rangeHash = file.getRangesHash()
|
|
if (hash) blobHashes.add(hash)
|
|
if (rangeHash) blobHashes.add(rangeHash)
|
|
}
|
|
// TODO(das7pad): refine types to enforce no nulls in FileMapData
|
|
// @ts-ignore
|
|
this.fileMap.map(find)
|
|
}
|
|
|
|
/**
|
|
* Load all of the files in this snapshot.
|
|
*
|
|
* @param {string} kind see {File#load}
|
|
* @param {ReadonlyBlobStore} blobStore
|
|
* @return {Promise<Record<string, File>>} an object where keys are the pathnames and
|
|
* values are the files in the snapshot
|
|
*/
|
|
async loadFiles(kind, blobStore) {
|
|
/**
|
|
* @param {File} file
|
|
*/
|
|
function load(file) {
|
|
return file.load(kind, blobStore)
|
|
}
|
|
// TODO(das7pad): refine types to enforce no nulls in FileMapData
|
|
// @ts-ignore
|
|
return await this.fileMap.mapAsync(load, FILE_LOAD_CONCURRENCY)
|
|
}
|
|
|
|
/**
|
|
* Store each of the files in this snapshot and return the raw snapshot for
|
|
* long term storage.
|
|
*
|
|
* @param {BlobStore} blobStore
|
|
* @param {number} [concurrency]
|
|
* @return {Promise.<Object>}
|
|
*/
|
|
async store(blobStore, concurrency) {
|
|
assert.maybe.number(concurrency, 'bad concurrency')
|
|
|
|
const projectVersion = this.projectVersion
|
|
const rawV2DocVersions = this.v2DocVersions
|
|
? this.v2DocVersions.toRaw()
|
|
: undefined
|
|
|
|
/**
|
|
* @param {File} file
|
|
*/
|
|
function store(file) {
|
|
return file.store(blobStore)
|
|
}
|
|
// TODO(das7pad): refine types to enforce no nulls in FileMapData
|
|
// @ts-ignore
|
|
const rawFiles = await this.fileMap.mapAsync(store, concurrency)
|
|
return {
|
|
files: rawFiles,
|
|
projectVersion,
|
|
v2DocVersions: rawV2DocVersions,
|
|
timestamp: this.getTimestamp() ?? undefined,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a deep clone of this snapshot.
|
|
*
|
|
* @return {Snapshot}
|
|
*/
|
|
clone() {
|
|
return Snapshot.fromRaw(this.toRaw())
|
|
}
|
|
}
|
|
|
|
module.exports = Snapshot
|