| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836 |
- const { callbackifyAll } = require('@overleaf/promise-utils')
- const RedisManager = require('./RedisManager')
- const ProjectHistoryRedisManager = require('./ProjectHistoryRedisManager')
- const PersistenceManager = require('./PersistenceManager')
- const DiffCodec = require('./DiffCodec')
- const logger = require('@overleaf/logger')
- const Metrics = require('./Metrics')
- const HistoryManager = require('./HistoryManager')
- const Errors = require('./Errors')
- const RangesManager = require('./RangesManager')
- const { extractOriginOrSource } = require('./Utils')
- const { getTotalSizeOfLines } = require('./Limits')
- const Settings = require('@overleaf/settings')
- const { StringFileData } = require('overleaf-editor-core')
- const MAX_UNFLUSHED_AGE = Settings.maxUnflushedAgeMs // document should be flushed to mongo this time after a change
- const DocumentManager = {
- /**
- * @param {string} projectId
- * @param {string} docId
- * @return {Promise<{lines: (string[] | StringFileRawData), version: number, ranges: Ranges, resolvedCommentIds: any[], pathname: string, projectHistoryId: string, unflushedTime: any, alreadyLoaded: boolean, historyRangesSupport: boolean, type: OTType}>}
- */
- async getDoc(projectId, docId) {
- const {
- lines,
- version,
- ranges,
- resolvedCommentIds,
- pathname,
- projectHistoryId,
- unflushedTime,
- historyRangesSupport,
- } = await RedisManager.promises.getDoc(projectId, docId)
- if (lines == null || version == null) {
- logger.debug(
- { projectId, docId },
- 'doc not in redis so getting from persistence API'
- )
- const {
- lines,
- version,
- ranges,
- resolvedCommentIds,
- pathname,
- projectHistoryId,
- historyRangesSupport,
- } = await PersistenceManager.promises.getDoc(projectId, docId)
- logger.debug(
- {
- projectId,
- docId,
- lines,
- ranges,
- resolvedCommentIds,
- version,
- pathname,
- projectHistoryId,
- historyRangesSupport,
- },
- 'got doc from persistence API'
- )
- await RedisManager.promises.putDocInMemory(
- projectId,
- docId,
- lines,
- version,
- ranges,
- resolvedCommentIds,
- pathname,
- projectHistoryId,
- historyRangesSupport
- )
- return {
- lines,
- version,
- ranges: ranges || {},
- resolvedCommentIds,
- pathname,
- projectHistoryId,
- unflushedTime: null,
- alreadyLoaded: false,
- historyRangesSupport,
- type: Array.isArray(lines) ? 'sharejs-text-ot' : 'history-ot',
- }
- } else {
- return {
- lines,
- version,
- ranges,
- pathname,
- projectHistoryId,
- resolvedCommentIds,
- unflushedTime,
- alreadyLoaded: true,
- historyRangesSupport,
- type: Array.isArray(lines) ? 'sharejs-text-ot' : 'history-ot',
- }
- }
- },
- async getDocAndRecentOps(projectId, docId, fromVersion) {
- const { lines, version, ranges, pathname, projectHistoryId, type } =
- await DocumentManager.getDoc(projectId, docId)
- if (fromVersion === -1) {
- return {
- lines,
- version,
- ops: [],
- ranges,
- pathname,
- projectHistoryId,
- type,
- }
- } else {
- const ops = await RedisManager.promises.getPreviousDocOps(
- docId,
- fromVersion,
- version
- )
- return {
- lines,
- version,
- ops,
- ranges,
- pathname,
- projectHistoryId,
- type,
- }
- }
- },
- async appendToDoc(projectId, docId, linesToAppend, originOrSource, userId) {
- let { lines: currentLines, type } = await DocumentManager.getDoc(
- projectId,
- docId
- )
- if (type === 'history-ot') {
- const file = StringFileData.fromRaw(currentLines)
- // TODO(24596): tc support for history-ot
- currentLines = file.getLines()
- }
- const currentLineSize = getTotalSizeOfLines(currentLines)
- const addedSize = getTotalSizeOfLines(linesToAppend)
- const newlineSize = '\n'.length
- if (currentLineSize + newlineSize + addedSize > Settings.max_doc_length) {
- throw new Errors.FileTooLargeError(
- 'doc would become too large if appending this text'
- )
- }
- return await DocumentManager.setDoc(
- projectId,
- docId,
- currentLines.concat(linesToAppend),
- originOrSource,
- userId,
- false,
- false
- )
- },
- async setDoc(
- projectId,
- docId,
- newLines,
- originOrSource,
- userId,
- undoing,
- external
- ) {
- if (newLines == null) {
- throw new Error('No lines were provided to setDoc')
- }
- // Circular dependencies. Import at runtime.
- const HistoryOTUpdateManager = require('./HistoryOTUpdateManager')
- const UpdateManager = require('./UpdateManager')
- const {
- lines: oldLines,
- version,
- alreadyLoaded,
- type,
- } = await DocumentManager.getDoc(projectId, docId)
- logger.debug(
- { docId, projectId, oldLines, newLines },
- 'setting a document via http'
- )
- let op
- if (type === 'history-ot') {
- const file = StringFileData.fromRaw(oldLines)
- const operation = DiffCodec.diffAsHistoryOTEditOperation(
- file,
- newLines.join('\n')
- )
- if (operation.isNoop()) {
- op = []
- } else {
- op = [operation.toJSON()]
- }
- } else {
- op = DiffCodec.diffAsShareJsOp(oldLines, newLines)
- if (undoing) {
- for (const o of op || []) {
- o.u = true
- } // Turn on undo flag for each op for track changes
- }
- }
- const { origin, source } = extractOriginOrSource(originOrSource)
- const update = {
- doc: docId,
- op,
- v: version,
- meta: {
- user_id: userId,
- },
- }
- if (external) {
- update.meta.type = 'external'
- }
- if (origin) {
- update.meta.origin = origin
- } else if (source) {
- update.meta.source = source
- }
- // Keep track of external updates, whether they are for live documents
- // (flush) or unloaded documents (evict), and whether the update is a no-op.
- Metrics.inc('external-update', 1, {
- status: op.length > 0 ? 'diff' : 'noop',
- method: alreadyLoaded ? 'flush' : 'evict',
- path: source,
- })
- // Do not notify the frontend about a noop update.
- // We still want to execute the code below
- // to evict the doc if we loaded it into redis for
- // this update, otherwise the doc would never be
- // removed from redis.
- if (op.length > 0) {
- if (type === 'history-ot') {
- await HistoryOTUpdateManager.applyUpdate(projectId, docId, update)
- } else {
- await UpdateManager.promises.applyUpdate(projectId, docId, update)
- }
- }
- // If the document was loaded already, then someone has it open
- // in a project, and the usual flushing mechanism will happen.
- // Otherwise we should remove it immediately since nothing else
- // is using it.
- if (alreadyLoaded) {
- return await DocumentManager.flushDocIfLoaded(projectId, docId)
- } else {
- try {
- return await DocumentManager.flushAndDeleteDoc(projectId, docId, {})
- } finally {
- // There is no harm in flushing project history if the previous
- // call failed and sometimes it is required
- HistoryManager.flushProjectChangesAsync(projectId)
- }
- }
- },
- async flushDocIfLoaded(projectId, docId) {
- let {
- lines,
- version,
- ranges,
- unflushedTime,
- lastUpdatedAt,
- lastUpdatedBy,
- } = await RedisManager.promises.getDoc(projectId, docId)
- if (lines == null || version == null) {
- Metrics.inc('flush-doc-if-loaded', 1, { status: 'not-loaded' })
- logger.debug({ projectId, docId }, 'doc is not loaded so not flushing')
- // TODO: return a flag to bail out, as we go on to remove doc from memory?
- return
- } else if (unflushedTime == null) {
- Metrics.inc('flush-doc-if-loaded', 1, { status: 'unmodified' })
- logger.debug({ projectId, docId }, 'doc is not modified so not flushing')
- return
- }
- logger.debug({ projectId, docId, version }, 'flushing doc')
- Metrics.inc('flush-doc-if-loaded', 1, { status: 'modified' })
- if (!Array.isArray(lines)) {
- const file = StringFileData.fromRaw(lines)
- // TODO(24596): tc support for history-ot
- lines = file.getLines()
- }
- const result = await PersistenceManager.promises.setDoc(
- projectId,
- docId,
- lines,
- version,
- ranges,
- lastUpdatedAt,
- lastUpdatedBy || null
- )
- await RedisManager.promises.clearUnflushedTime(docId)
- return result
- },
- async flushAndDeleteDoc(projectId, docId, options) {
- let result
- try {
- result = await DocumentManager.flushDocIfLoaded(projectId, docId)
- } catch (error) {
- if (options.ignoreFlushErrors) {
- logger.warn(
- { projectId, docId, err: error },
- 'ignoring flush error while deleting document'
- )
- } else {
- throw error
- }
- }
- await RedisManager.promises.removeDocFromMemory(projectId, docId)
- return result
- },
- async acceptChanges(projectId, docId, changeIds) {
- if (changeIds == null) {
- changeIds = []
- }
- let changeContributors = []
- const {
- lines,
- version,
- ranges,
- pathname,
- projectHistoryId,
- historyRangesSupport,
- } = await DocumentManager.getDoc(projectId, docId)
- if (lines == null || version == null) {
- throw new Errors.NotFoundError(`document not found: ${docId}`)
- }
- // TODO(24596): tc support for history-ot
- const newRanges = RangesManager.acceptChanges(
- projectId,
- docId,
- changeIds,
- ranges,
- lines
- )
- await RedisManager.promises.updateDocument(
- projectId,
- docId,
- lines,
- version,
- [],
- newRanges,
- {}
- )
- if (historyRangesSupport) {
- const historyUpdates = RangesManager.getHistoryUpdatesForAcceptedChanges({
- docId,
- acceptedChangeIds: changeIds,
- changes: ranges.changes || [],
- lines,
- pathname,
- projectHistoryId,
- })
- if (historyUpdates.length === 0) {
- return changeContributors
- }
- await ProjectHistoryRedisManager.promises.queueOps(
- projectId,
- ...historyUpdates.map(op => JSON.stringify(op))
- )
- }
- changeContributors = (ranges.changes || [])
- .filter(change => changeIds.includes(change.id))
- .map(change => change?.metadata?.user_id)
- .filter(userId => userId)
- return changeContributors
- },
- async rejectChanges(projectId, docId, changeIds, userId) {
- const UpdateManager = require('./UpdateManager')
- const HistoryOTUpdateManager = require('./HistoryOTUpdateManager')
- const { lines, version, ranges } = await DocumentManager.getDoc(
- projectId,
- docId
- )
- if (lines == null || version == null) {
- throw new Errors.NotFoundError(`document not found: ${docId}`)
- }
- const changesToReject = ranges.changes
- ? ranges.changes.filter(change => changeIds.includes(change.id))
- : []
- // Apply inverted operations for rejected changes (based on reject-changes.ts logic)
- // Sort changes in reverse order by position to avoid conflicts
- changesToReject.sort((a, b) => b.op.p - a.op.p)
- const ops = []
- for (const change of changesToReject) {
- if (change.op.i) {
- const deleteOp = {
- p: change.op.p,
- d: change.op.i,
- u: true,
- }
- ops.push(deleteOp)
- } else if (change.op.d) {
- const insertOp = {
- p: change.op.p,
- i: change.op.d,
- u: true,
- }
- ops.push(insertOp)
- }
- }
- const update = {
- doc: docId,
- op: ops,
- v: version,
- meta: {
- user_id: userId,
- ts: new Date().toISOString(),
- },
- }
- if (HistoryOTUpdateManager.isHistoryOTEditOperationUpdate(update)) {
- await HistoryOTUpdateManager.applyUpdate(projectId, docId, update)
- } else {
- await UpdateManager.promises.applyUpdate(projectId, docId, update)
- }
- return { rejectedChangeIds: changesToReject.map(c => c.id) }
- },
- async updateCommentState(projectId, docId, commentId, userId, resolved) {
- const { lines, version, pathname, historyRangesSupport } =
- await DocumentManager.getDoc(projectId, docId)
- if (lines == null || version == null) {
- throw new Errors.NotFoundError(`document not found: ${docId}`)
- }
- if (historyRangesSupport) {
- await RedisManager.promises.updateCommentState(docId, commentId, resolved)
- await ProjectHistoryRedisManager.promises.queueOps(
- projectId,
- JSON.stringify({
- pathname,
- commentId,
- resolved,
- meta: {
- ts: new Date(),
- user_id: userId,
- },
- })
- )
- }
- },
- async getComment(projectId, docId, commentId) {
- // TODO(24596): tc support for history-ot
- const { ranges } = await DocumentManager.getDoc(projectId, docId)
- const comment = ranges?.comments?.find(comment => comment.id === commentId)
- if (!comment) {
- throw new Errors.NotFoundError({
- message: 'comment not found',
- info: { commentId },
- })
- }
- return comment
- },
- async deleteComment(projectId, docId, commentId, userId) {
- const { lines, version, ranges, pathname, historyRangesSupport } =
- await DocumentManager.getDoc(projectId, docId)
- if (lines == null || version == null) {
- throw new Errors.NotFoundError(`document not found: ${docId}`)
- }
- // TODO(24596): tc support for history-ot
- const newRanges = RangesManager.deleteComment(commentId, ranges)
- await RedisManager.promises.updateDocument(
- projectId,
- docId,
- lines,
- version,
- [],
- newRanges,
- {}
- )
- if (historyRangesSupport) {
- await RedisManager.promises.updateCommentState(docId, commentId, false)
- await ProjectHistoryRedisManager.promises.queueOps(
- projectId,
- JSON.stringify({
- pathname,
- deleteComment: commentId,
- meta: {
- ts: new Date(),
- user_id: userId,
- },
- })
- )
- }
- },
- async renameDoc(projectId, docId, userId, update, projectHistoryId) {
- await RedisManager.promises.renameDoc(
- projectId,
- docId,
- userId,
- update,
- projectHistoryId
- )
- },
- async getDocAndFlushIfOld(projectId, docId) {
- let { lines, version, unflushedTime, alreadyLoaded } =
- await DocumentManager.getDoc(projectId, docId)
- // if doc was already loaded see if it needs to be flushed
- if (
- alreadyLoaded &&
- unflushedTime != null &&
- Date.now() - unflushedTime > MAX_UNFLUSHED_AGE
- ) {
- await DocumentManager.flushDocIfLoaded(projectId, docId)
- }
- if (!Array.isArray(lines)) {
- const file = StringFileData.fromRaw(lines)
- // TODO(24596): tc support for history-ot
- lines = file.getLines()
- }
- return { lines, version }
- },
- async resyncDocContents(projectId, docId, path, opts = {}) {
- logger.debug({ projectId, docId, path }, 'start resyncing doc contents')
- let {
- lines,
- ranges,
- resolvedCommentIds,
- version,
- projectHistoryId,
- historyRangesSupport,
- } = await RedisManager.promises.getDoc(projectId, docId)
- // To avoid issues where the same docId appears with different paths,
- // we use the path from the resyncProjectStructure update. If we used
- // the path from the getDoc call to web then the two occurences of the
- // docId would map to the same path, and this would be rejected by
- // project-history as an unexpected resyncDocContent update.
- if (lines == null || version == null) {
- logger.debug(
- { projectId, docId },
- 'resyncing doc contents - not found in redis - retrieving from web'
- )
- ;({
- lines,
- ranges,
- resolvedCommentIds,
- version,
- projectHistoryId,
- historyRangesSupport,
- } = await PersistenceManager.promises.getDoc(projectId, docId, {
- peek: true,
- }))
- } else {
- logger.debug(
- { projectId, docId },
- 'resyncing doc contents - doc in redis - will queue in redis'
- )
- }
- if (opts.historyRangesMigration) {
- historyRangesSupport = opts.historyRangesMigration === 'forwards'
- }
- await ProjectHistoryRedisManager.promises.queueResyncDocContent(
- projectId,
- projectHistoryId,
- docId,
- lines,
- ranges ?? {},
- resolvedCommentIds,
- version,
- // use the path from the resyncProjectStructure update
- path,
- historyRangesSupport
- )
- if (opts.historyRangesMigration) {
- await RedisManager.promises.setHistoryRangesSupportFlag(
- docId,
- historyRangesSupport
- )
- }
- },
- async getDocWithLock(projectId, docId) {
- const UpdateManager = require('./UpdateManager')
- return await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.getDoc,
- projectId,
- docId
- )
- },
- async getCommentWithLock(projectId, docId, commentId) {
- const UpdateManager = require('./UpdateManager')
- return await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.getComment,
- projectId,
- docId,
- commentId
- )
- },
- async getDocAndRecentOpsWithLock(projectId, docId, fromVersion) {
- const UpdateManager = require('./UpdateManager')
- return await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.getDocAndRecentOps,
- projectId,
- docId,
- fromVersion
- )
- },
- async getDocAndFlushIfOldWithLock(projectId, docId) {
- const UpdateManager = require('./UpdateManager')
- return await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.getDocAndFlushIfOld,
- projectId,
- docId
- )
- },
- async setDocWithLock(
- projectId,
- docId,
- lines,
- source,
- userId,
- undoing,
- external
- ) {
- const UpdateManager = require('./UpdateManager')
- return await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.setDoc,
- projectId,
- docId,
- lines,
- source,
- userId,
- undoing,
- external
- )
- },
- async appendToDocWithLock(projectId, docId, lines, source, userId) {
- const UpdateManager = require('./UpdateManager')
- return await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.appendToDoc,
- projectId,
- docId,
- lines,
- source,
- userId
- )
- },
- async flushDocIfLoadedWithLock(projectId, docId) {
- const UpdateManager = require('./UpdateManager')
- return await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.flushDocIfLoaded,
- projectId,
- docId
- )
- },
- async flushAndDeleteDocWithLock(projectId, docId, options) {
- const UpdateManager = require('./UpdateManager')
- return await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.flushAndDeleteDoc,
- projectId,
- docId,
- options
- )
- },
- async acceptChangesWithLock(projectId, docId, changeIds) {
- const UpdateManager = require('./UpdateManager')
- const changeContributors = await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.acceptChanges,
- projectId,
- docId,
- changeIds
- )
- return changeContributors
- },
- async rejectChangesWithLock(projectId, docId, changeIds, userId) {
- const UpdateManager = require('./UpdateManager')
- return await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.rejectChanges,
- projectId,
- docId,
- changeIds,
- userId
- )
- },
- async updateCommentStateWithLock(
- projectId,
- docId,
- threadId,
- userId,
- resolved
- ) {
- const UpdateManager = require('./UpdateManager')
- await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.updateCommentState,
- projectId,
- docId,
- threadId,
- userId,
- resolved
- )
- },
- async deleteCommentWithLock(projectId, docId, threadId, userId) {
- const UpdateManager = require('./UpdateManager')
- await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.deleteComment,
- projectId,
- docId,
- threadId,
- userId
- )
- },
- async renameDocWithLock(projectId, docId, userId, update, projectHistoryId) {
- const UpdateManager = require('./UpdateManager')
- await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.renameDoc,
- projectId,
- docId,
- userId,
- update,
- projectHistoryId
- )
- },
- async resyncDocContentsWithLock(projectId, docId, path, opts) {
- const UpdateManager = require('./UpdateManager')
- await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.resyncDocContents,
- projectId,
- docId,
- path,
- opts
- )
- },
- }
- module.exports = {
- ...callbackifyAll(DocumentManager, {
- multiResult: {
- getDoc: [
- 'lines',
- 'version',
- 'ranges',
- 'pathname',
- 'projectHistoryId',
- 'unflushedTime',
- 'alreadyLoaded',
- 'historyRangesSupport',
- ],
- getDocWithLock: [
- 'lines',
- 'version',
- 'ranges',
- 'pathname',
- 'projectHistoryId',
- 'unflushedTime',
- 'alreadyLoaded',
- 'historyRangesSupport',
- ],
- getDocAndFlushIfOld: ['lines', 'version'],
- getDocAndFlushIfOldWithLock: ['lines', 'version'],
- getDocAndRecentOps: [
- 'lines',
- 'version',
- 'ops',
- 'ranges',
- 'pathname',
- 'projectHistoryId',
- 'type',
- ],
- getDocAndRecentOpsWithLock: [
- 'lines',
- 'version',
- 'ops',
- 'ranges',
- 'pathname',
- 'projectHistoryId',
- 'type',
- ],
- },
- }),
- promises: DocumentManager,
- }
|