overleaf/services/web/scripts/history/HistoryUpgradeHelper.js
Eric Mc Sween 8b2f8ce243 Merge pull request #9383 from overleaf/em-file-tree-histories
Track source for all file tree operations

GitOrigin-RevId: ff95ea8e99bfa30203a2a42968519bbaba65e708
2022-08-26 08:03:30 +00:00

340 lines
11 KiB
JavaScript

const { ReadPreference, ObjectId } = require('mongodb')
const { db } = require('../../app/src/infrastructure/mongodb')
const Settings = require('@overleaf/settings')
const ProjectHistoryHandler = require('../../app/src/Features/Project/ProjectHistoryHandler')
const HistoryManager = require('../../app/src/Features/History/HistoryManager')
const ProjectHistoryController = require('../../modules/admin-panel/app/src/ProjectHistoryController')
const ProjectEntityHandler = require('../../app/src/Features/Project/ProjectEntityHandler')
const ProjectEntityUpdateHandler = require('../../app/src/Features/Project/ProjectEntityUpdateHandler')
// Timestamp of when 'Enable history for SL in background' release
const ID_WHEN_FULL_PROJECT_HISTORY_ENABLED = '5a8d8a370000000000000000'
const OBJECT_ID_WHEN_FULL_PROJECT_HISTORY_ENABLED = new ObjectId(
ID_WHEN_FULL_PROJECT_HISTORY_ENABLED
)
const DATETIME_WHEN_FULL_PROJECT_HISTORY_ENABLED =
OBJECT_ID_WHEN_FULL_PROJECT_HISTORY_ENABLED.getTimestamp()
async function determineProjectHistoryType(project) {
if (project.overleaf && project.overleaf.history) {
if (project.overleaf.history.upgradeFailed) {
return 'UpgradeFailed'
}
if (project.overleaf.history.conversionFailed) {
return 'ConversionFailed'
}
}
if (
project.overleaf &&
project.overleaf.history &&
project.overleaf.history.id
) {
if (project.overleaf.history.display) {
// v2: full project history, do nothing
return 'V2'
} else {
if (projectCreatedAfterFullProjectHistoryEnabled(project)) {
// IF project initialised after full project history enabled for all projects
// THEN project history should contain all information we need, without intervention
return 'V1WithoutConversion'
} else {
// ELSE SL history may predate full project history
// THEN delete full project history and convert their SL history to full project history
// --
// TODO: how to verify this, can get rough start date of SL history, but not full project history
const preserveHistory = await shouldPreserveHistory(project)
const anyDocHistory = await anyDocHistoryExists(project)
const anyDocHistoryIndex = await anyDocHistoryIndexExists(project)
if (preserveHistory) {
if (anyDocHistory || anyDocHistoryIndex) {
// if SL history exists that we need to preserve, then we must convert
return 'V1WithConversion'
} else {
// otherwise just upgrade without conversion
return 'V1WithoutConversion'
}
} else {
// if preserveHistory false, then max 7 days of SL history
// but v1 already record to both histories, so safe to upgrade
return 'V1WithoutConversion'
}
}
}
} else {
const preserveHistory = await shouldPreserveHistory(project)
const anyDocHistory = await anyDocHistoryExists(project)
const anyDocHistoryIndex = await anyDocHistoryIndexExists(project)
if (anyDocHistory || anyDocHistoryIndex) {
// IF there is SL history ->
if (preserveHistory) {
// that needs to be preserved:
// THEN initialise full project history and convert SL history to full project history
return 'NoneWithConversion'
} else {
return 'NoneWithTemporaryHistory'
}
} else {
// ELSE there is not any SL history ->
// THEN initialise full project history and sync with current content
return 'NoneWithoutConversion'
}
}
}
async function upgradeProject(project) {
const historyType = await determineProjectHistoryType(project)
if (historyType === 'V2') {
return { historyType, upgraded: true }
}
const upgradeFn = getUpgradeFunctionForType(historyType)
if (!upgradeFn) {
return { error: 'unsupported history type' }
}
const result = await upgradeFn(project)
result.historyType = historyType
return result
}
// Do upgrades/conversion:
function getUpgradeFunctionForType(historyType) {
return UpgradeFunctionMapping[historyType]
}
const UpgradeFunctionMapping = {
NoneWithoutConversion: doUpgradeForNoneWithoutConversion,
UpgradeFailed: doUpgradeForNoneWithoutConversion,
ConversionFailed: doUpgradeForNoneWithConversion,
V1WithoutConversion: doUpgradeForV1WithoutConversion,
V1WithConversion: doUpgradeForV1WithConversion,
NoneWithConversion: doUpgradeForNoneWithConversion,
NoneWithTemporaryHistory: doUpgradeForNoneWithConversion,
}
async function doUpgradeForV1WithoutConversion(project) {
await db.projects.updateOne(
{ _id: project._id },
{
$set: {
'overleaf.history.display': true,
'overleaf.history.upgradedAt': new Date(),
'overleaf.history.upgradeReason': `v1-without-sl-history`,
},
}
)
return { upgraded: true }
}
async function doUpgradeForV1WithConversion(project) {
const result = {}
const projectId = project._id
// migrateProjectHistory expects project id as a string
const projectIdString = project._id.toString()
try {
// We treat these essentially as None projects, the V1 history is irrelevant,
// so we will delete it, and do a conversion as if we're a None project
await ProjectHistoryController.deleteProjectHistory(projectIdString)
await ProjectHistoryController.migrateProjectHistory(projectIdString)
} catch (err) {
// if migrateProjectHistory fails, it cleans up by deleting
// the history and unsetting the history id
// therefore a failed project will still look like a 'None with conversion' project
result.error = err
await db.projects.updateOne(
{ _id: projectId },
{
$set: {
'overleaf.history.conversionFailed': true,
},
}
)
return result
}
await db.projects.updateOne(
{ _id: projectId },
{
$set: {
'overleaf.history.upgradeReason': `v1-with-conversion`,
},
$unset: {
'overleaf.history.upgradeFailed': true,
'overleaf.history.conversionFailed': true,
},
}
)
result.upgraded = true
return result
}
async function doUpgradeForNoneWithoutConversion(project) {
const result = {}
const projectId = project._id
try {
// Logic originally from ProjectHistoryHandler.ensureHistoryExistsForProject
// However sends a force resync project to project history instead
// of a resync request to doc-updater
const historyId = await ProjectHistoryHandler.promises.getHistoryId(
projectId
)
if (!historyId) {
const history = await HistoryManager.promises.initializeProject()
if (history && history.overleaf_id) {
await ProjectHistoryHandler.promises.setHistoryId(
projectId,
history.overleaf_id
)
}
}
await HistoryManager.promises.resyncProject(projectId, {
force: true,
origin: { kind: 'history-migration' },
})
await HistoryManager.promises.flushProject(projectId)
} catch (err) {
result.error = err
await db.projects.updateOne(
{ _id: project._id },
{
$set: {
'overleaf.history.upgradeFailed': true,
},
}
)
return result
}
await db.projects.updateOne(
{ _id: project._id },
{
$set: {
'overleaf.history.display': true,
'overleaf.history.upgradedAt': new Date(),
'overleaf.history.upgradeReason': `none-without-conversion`,
},
}
)
result.upgraded = true
return result
}
async function doUpgradeForNoneWithConversion(project) {
const result = {}
const projectId = project._id
// migrateProjectHistory expects project id as a string
const projectIdString = project._id.toString()
try {
await ProjectHistoryController.migrateProjectHistory(projectIdString)
} catch (err) {
// if migrateProjectHistory fails, it cleans up by deleting
// the history and unsetting the history id
// therefore a failed project will still look like a 'None with conversion' project
result.error = err
await db.projects.updateOne(
{ _id: projectId },
{
$set: {
'overleaf.history.conversionFailed': true,
},
}
)
return result
}
await db.projects.updateOne(
{ _id: projectId },
{
$set: {
'overleaf.history.upgradeReason': `none-with-conversion`,
},
$unset: {
'overleaf.history.upgradeFailed': true,
'overleaf.history.conversionFailed': true,
},
}
)
result.upgraded = true
return result
}
// Util
function projectCreatedAfterFullProjectHistoryEnabled(project) {
return (
project._id.getTimestamp() >= DATETIME_WHEN_FULL_PROJECT_HISTORY_ENABLED
)
}
async function shouldPreserveHistory(project) {
return await db.projectHistoryMetaData.findOne(
{
$and: [
{ project_id: { $eq: project._id } },
{ preserveHistory: { $eq: true } },
],
},
{ readPreference: ReadPreference.SECONDARY }
)
}
async function anyDocHistoryExists(project) {
return await db.docHistory.findOne(
{ project_id: { $eq: project._id } },
{
projection: { _id: 1 },
readPreference: ReadPreference.SECONDARY,
}
)
}
async function anyDocHistoryIndexExists(project) {
return await db.docHistoryIndex.findOne(
{ project_id: { $eq: project._id } },
{
projection: { _id: 1 },
readPreference: ReadPreference.SECONDARY,
}
)
}
async function convertLargeDocsToFile(projectId, userId) {
const docs = await ProjectEntityHandler.promises.getAllDocs(projectId)
let convertedDocCount = 0
for (const doc of Object.values(docs)) {
const sizeBound = JSON.stringify(doc.lines)
if (docIsTooLarge(sizeBound, doc.lines, Settings.max_doc_length)) {
await ProjectEntityUpdateHandler.promises.convertDocToFile(
projectId,
doc._id,
userId,
null
)
convertedDocCount++
}
}
return convertedDocCount
}
// check whether the total size of the document in characters exceeds the
// maxDocLength.
//
// Copied from document-updater:
// https://github.com/overleaf/internal/blob/74adfbebda5f3c2c37d9937f0db5c4106ecde492/services/document-updater/app/js/Limits.js#L18
function docIsTooLarge(estimatedSize, lines, maxDocLength) {
if (estimatedSize <= maxDocLength) {
return false // definitely under the limit, no need to calculate the total size
}
// calculate the total size, bailing out early if the size limit is reached
let size = 0
for (const line of lines) {
size += line.length + 1 // include the newline
if (size > maxDocLength) return true
}
// since we didn't hit the limit in the loop, the document is within the allowed length
return false
}
module.exports = {
determineProjectHistoryType,
getUpgradeFunctionForType,
upgradeProject,
convertLargeDocsToFile,
}