Files
overleaf-cep/libraries/overleaf-editor-core/lib/schemas.js
T
e039ad26c5 [editor-core] extract logic for determining root doc from snapshot (#37043)
* [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
2026-09-17 12:08:31 +00:00

393 lines
11 KiB
JavaScript

// @ts-check
'use strict'
const { z, zz } = require('@overleaf/validation-tools')
const rawTrackingProps = z.strictObject({
type: z.enum(['insert', 'delete']),
userId: zz.objectId(),
ts: z.iso.datetime(),
})
const rawClearTrackingProps = z.strictObject({
type: z.literal('none'),
})
const rawInsertOp = z.union([
z.strictObject({
i: z.string(),
commentIds: z.array(zz.objectId()).optional(),
tracking: rawTrackingProps.optional(),
}),
z.string(),
])
const rawRemoveOp = z.number().int().max(-1)
const rawRetainOp = z.union([
z.strictObject({
r: z.number().int().min(1),
commentIds: z.array(zz.objectId()).optional(),
tracking: z
.discriminatedUnion('type', [rawTrackingProps, rawClearTrackingProps])
.optional(),
}),
z.number().int().min(1),
])
const rawScanOp = z.union([rawInsertOp, rawRemoveOp, rawRetainOp])
const rawTextOperation = z.strictObject({
textOperation: z.array(rawScanOp),
contentHash: z.string().optional(),
})
const rawRange = z.strictObject({
pos: z.number().int().min(0),
length: z.number().int().min(0),
})
const rawAddCommentOperation = z.strictObject({
commentId: zz.objectId(),
ranges: z.array(rawRange),
resolved: z.boolean().optional(),
})
const rawSetCommentStateOperation = z.strictObject({
commentId: zz.objectId(),
resolved: z.boolean(),
})
const rawDeleteCommentOperation = z.strictObject({
deleteComment: zz.objectId(),
})
const rawEditNoOperation = z.strictObject({
noOp: z.literal(true),
})
const rawEditOperation = z.union([
rawTextOperation,
rawAddCommentOperation,
rawDeleteCommentOperation,
rawSetCommentStateOperation,
rawEditNoOperation,
])
const rawComment = z.strictObject({
id: zz.objectId(),
ranges: z.array(rawRange),
resolved: z.boolean().optional(),
})
const rawTrackedChange = z.strictObject({
range: rawRange,
tracking: rawTrackingProps,
})
const rawStringFileData = z.strictObject({
content: z.string(),
comments: z.array(rawComment).optional(),
trackedChanges: z.array(rawTrackedChange).optional(),
})
// linkedFileData as written by the web linked-file agents (all-scalar; see
// the per-provider _sanitizeData implementations in web). importedAt is
// always written on create/refresh but may be absent on very old documents.
const rawLinkedFileData = z.discriminatedUnion('provider', [
z.strictObject({
provider: z.literal('url'),
url: z.string(),
importedAt: z.iso.datetime().optional(),
}),
z.strictObject({
provider: z.literal('project_file'),
source_project_id: zz.objectId().optional(),
v1_source_doc_id: z.number().optional(),
source_entity_path: z.string(),
// written by the linked-file agents until 2018 (removed in ebe828aa625)
// and never since; still present in the history of projects that linked a
// file before then, so it has to be tolerated when replaying changes.
source_project_display_name: z.string().optional(),
importedAt: z.iso.datetime().optional(),
}),
z.strictObject({
provider: z.literal('project_output_file'),
source_project_id: zz.objectId().optional(),
v1_source_doc_id: z.number().optional(),
source_output_file_path: z.string(),
build_id: zz.buildId().optional(),
compileGroup: zz.compileGroup().optional(),
clsiServerId: zz.clsiServerId().optional(),
importedAt: z.iso.datetime().optional(),
}),
z.strictObject({
provider: z.literal('mendeley'),
// null, not just absent, for an import from a personal library: the web
// agents always build the data object with a group_id key, so no group
// means `group_id: undefined`, and mongoose keeps that key when $set-ing
// the Mixed linkedFileData (upserting over an existing file), where the
// driver serializes it as null. Same for the zotero/papers branches.
group_id: zz.routeSegment().nullish(),
importer_id: z.string().optional(),
v1_importer_id: z.number().optional(),
importedAt: z.iso.datetime().optional(),
}),
z.strictObject({
provider: z.literal('zotero'),
// absent on files imported before the `format` field existed; agents
// default this to 'bibtex' (ZoteroAgent `_getFormat`)
format: z.enum(['bibtex', 'biblatex']).optional(),
group_id: zz.routeSegment().nullish(),
importer_id: z.string().optional(),
v1_importer_id: z.number().optional(),
importedAt: z.iso.datetime().optional(),
}),
z.strictObject({
provider: z.literal('papers'),
group_id: zz.routeSegment().nullish(),
importer_id: z.string().optional(),
v1_importer_id: z.number().optional(),
importedAt: z.iso.datetime().optional(),
}),
])
const rawBlobHash = z.string().regex(/^[0-9a-f]{40}$/, {
message: 'invalid blob hash',
})
const rawFileMetadata = z.union([
// doc/clear metadata
z.strictObject({}),
// regular file v2
z.strictObject({
importedAt: z.iso.datetime(),
}),
// linked-file v2
rawLinkedFileData,
// main-file v1, and the doc flags the editor sets
z.strictObject({
main: z.boolean().optional(),
mainBibliography: z.boolean().optional(),
importedAt: z.iso.datetime().optional(),
}),
// legacy v1 linked file
z.strictObject({
agent: z.string(),
agentDataId: z.number(),
importedAt: z.iso.datetime().optional(),
}),
])
const rawHashFileData = z.strictObject({
hash: rawBlobHash,
rangesHash: rawBlobHash.optional(),
})
const rawBinaryFileData = z.strictObject({
hash: rawBlobHash,
byteLength: z.number().int().min(0),
})
const rawLazyStringFileData = z.strictObject({
hash: rawBlobHash,
stringLength: z.number().int().min(0),
rangesHash: rawBlobHash.optional(),
operations: z.array(rawEditOperation).optional(),
})
const rawHollowBinaryFileData = z.strictObject({
byteLength: z.number().int().min(0),
})
const rawHollowStringFileData = z.strictObject({
stringLength: z.number().int().min(0),
})
const rawFileData = z.union([
rawBinaryFileData,
rawHashFileData,
rawHollowBinaryFileData,
rawHollowStringFileData,
rawLazyStringFileData,
rawStringFileData,
])
// the strict variants disambiguate the union by their key sets
const rawFile = z.union([
rawBinaryFileData.extend({ metadata: rawFileMetadata.optional() }),
rawHashFileData.extend({ metadata: rawFileMetadata.optional() }),
rawHollowBinaryFileData.extend({ metadata: rawFileMetadata.optional() }),
rawHollowStringFileData.extend({ metadata: rawFileMetadata.optional() }),
rawLazyStringFileData.extend({ metadata: rawFileMetadata.optional() }),
rawStringFileData.extend({ metadata: rawFileMetadata.optional() }),
])
const rawFileMap = z.record(z.string(), rawFile)
const rawV2DocVersions = z.record(
z.string(),
z.strictObject({
pathname: z.string(),
v: z.number().int(),
})
)
const rawSnapshot = z.strictObject({
files: rawFileMap,
projectVersion: z.string().optional(),
v2DocVersions: rawV2DocVersions.nullish(),
timestamp: z.iso.datetime().optional(),
})
// mirrors Origin.fromRaw: three restore variants plus a generic kind
//
// Every variant may carry the id of the client that submitted the change, which
// is what lets history recognise a resend and the client recognise its own change
// coming back. It is optional throughout: most changes are ones nothing has to
// recognise again, and the id is dropped from the rest once they can no longer be
// resent, so a bare {kind} is the common shape.
const historyClientId = z.uuid().optional()
// The kinds that have a shape of their own, which the variants below describe. A
// payload claiming one of them has to match that shape rather than falling through
// to the catch-all, where it would validate as a kind and nothing else and then be
// read back as a change that has lost what the kind promised.
const namedOriginKinds = ['restore', 'file-restore', 'project-restore']
// The fields every origin has, which is also what an origin with nothing but a kind
// serialises to -- Origin's own raw form, and the union's catch-all below.
const rawBaseOrigin = z.strictObject({
kind: z.string().refine(kind => !namedOriginKinds.includes(kind), {
message: 'origin kind has a shape of its own',
}),
historyClientId,
})
const rawRestoreOrigin = z.strictObject({
kind: z.literal('restore'),
version: z.number().int(),
timestamp: z.iso.datetime(),
historyClientId,
})
const rawRestoreFileOrigin = z.strictObject({
kind: z.literal('file-restore'),
version: z.number().int(),
path: z.string(),
timestamp: z.iso.datetime(),
historyClientId,
})
const rawRestoreProjectOrigin = z.strictObject({
kind: z.literal('project-restore'),
version: z.number().int(),
timestamp: z.iso.datetime(),
historyClientId,
})
// The named kinds discriminate on `kind`, so a payload claiming one of them is
// checked against that shape alone and the error says which field it got wrong. The
// catch-all cannot join them -- its kind is any string, which zod rejects as a
// discriminator -- so it sits alongside, and its refinement above is what stops it
// swallowing a named kind.
const rawOrigin = z.union([
z.discriminatedUnion('kind', [
rawRestoreOrigin,
rawRestoreFileOrigin,
rawRestoreProjectOrigin,
]),
rawBaseOrigin,
])
// mirrors Operation.fromRaw: add file / edit file / move (or delete) file /
// set file metadata / no-op
const rawAddFileOperation = z.strictObject({
pathname: z.string(),
file: rawFile,
})
const rawMoveFileOperation = z.strictObject({
pathname: z.string(),
newPathname: z.string(),
})
const rawEditFileOperation = z.union([
rawTextOperation.extend({ pathname: z.string() }),
rawAddCommentOperation.extend({ pathname: z.string() }),
rawDeleteCommentOperation.extend({ pathname: z.string() }),
rawSetCommentStateOperation.extend({ pathname: z.string() }),
rawEditNoOperation.extend({ pathname: z.string() }),
])
const rawSetFileMetadataOperation = z.strictObject({
pathname: z.string(),
metadata: rawFileMetadata,
})
const rawNoOperation = z.strictObject({})
const rawOperation = z.union([
rawAddFileOperation,
rawEditFileOperation,
rawMoveFileOperation,
rawSetFileMetadataOperation,
rawNoOperation,
])
const rawChange = z.strictObject({
operations: z.array(rawOperation),
timestamp: z.iso.datetime(),
// null represents an anonymous author
authors: z.array(z.number().int().nullable()).optional(),
v2Authors: z.array(zz.objectId().nullable()).optional(),
origin: rawOrigin.optional(),
projectVersion: z.string().optional(),
v2DocVersions: rawV2DocVersions.optional(),
})
module.exports = {
rawTrackingProps,
rawClearTrackingProps,
rawInsertOp,
rawRemoveOp,
rawRetainOp,
rawScanOp,
rawTextOperation,
rawRange,
rawAddCommentOperation,
rawSetCommentStateOperation,
rawDeleteCommentOperation,
rawEditNoOperation,
rawEditOperation,
rawComment,
rawTrackedChange,
rawStringFileData,
rawLinkedFileData,
rawBlobHash,
rawFileMetadata,
rawHashFileData,
rawBinaryFileData,
rawLazyStringFileData,
rawHollowBinaryFileData,
rawHollowStringFileData,
rawFileData,
rawFile,
rawFileMap,
rawV2DocVersions,
rawSnapshot,
rawBaseOrigin,
rawRestoreOrigin,
rawRestoreFileOrigin,
rawRestoreProjectOrigin,
rawOrigin,
rawAddFileOperation,
rawMoveFileOperation,
rawEditFileOperation,
rawSetFileMetadataOperation,
rawNoOperation,
rawOperation,
rawChange,
}