SyncManager.js 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500
  1. // @ts-check
  2. import _ from 'lodash'
  3. import { callbackify, promisify } from 'node:util'
  4. import { callbackifyMultiResult } from '@overleaf/promise-utils'
  5. import Settings from '@overleaf/settings'
  6. import logger from '@overleaf/logger'
  7. import Metrics from '@overleaf/metrics'
  8. import OError from '@overleaf/o-error'
  9. import { File, Range } from 'overleaf-editor-core'
  10. import {
  11. NeedFullProjectStructureResyncError,
  12. SYNC_ONGOING_ERROR_MESSAGE,
  13. SyncError,
  14. } from './Errors.js'
  15. import { db, ObjectId } from './mongodb.js'
  16. import * as SnapshotManager from './SnapshotManager.js'
  17. import * as LockManager from './LockManager.js'
  18. import * as UpdateTranslator from './UpdateTranslator.js'
  19. import * as UpdateCompressor from './UpdateCompressor.js'
  20. import * as WebApiManager from './WebApiManager.js'
  21. import * as ErrorRecorder from './ErrorRecorder.js'
  22. import * as RedisManager from './RedisManager.js'
  23. import * as HistoryStoreManager from './HistoryStoreManager.js'
  24. import * as HashManager from './HashManager.js'
  25. import { isInsert, isDelete } from './Utils.js'
  26. /**
  27. * @import { Comment as HistoryComment, TrackedChange as HistoryTrackedChange } from 'overleaf-editor-core'
  28. * @import { CommentRawData, TrackedChangeRawData } from 'overleaf-editor-core/lib/types'
  29. * @import { Comment, Entity, ResyncDocContentUpdate, RetainOp, TrackedChange } from './types'
  30. * @import { TrackedChangeTransition, TrackingDirective, TrackingType, Update } from './types'
  31. * @import { ProjectStructureUpdate } from './types'
  32. */
  33. const MAX_RESYNC_HISTORY_RECORDS = 100 // keep this many records of previous resyncs
  34. const EXPIRE_RESYNC_HISTORY_INTERVAL_MS = 90 * 24 * 3600 * 1000 // 90 days
  35. const SYNC_STUCK_TIMEOUT_MS = 4 * 60 * 60 * 1000 // 4 hours
  36. const MAX_STUCK_CLEAR_ATTEMPTS = 5
  37. const keys = Settings.redis.lock.key_schema
  38. // db.projectHistorySyncState.ensureIndex({expiresAt: 1}, {expireAfterSeconds: 0, background: true})
  39. // To add expiresAt field to existing entries in collection (choose a suitable future expiry date):
  40. // db.projectHistorySyncState.updateMany({resyncProjectStructure: false, resyncDocContents: [], expiresAt: {$exists:false}}, {$set: {expiresAt: new Date("2019-07-01")}})
  41. async function startResync(projectId, options = {}) {
  42. // We have three options here
  43. //
  44. // 1. If we update mongo before making the call to web then there's a
  45. // chance we ignore all updates indefinitely (there's no foolproff way
  46. // to undo the change in mongo)
  47. //
  48. // 2. If we make the call to web first then there is a small race condition
  49. // where we could process the sync update and then only update mongo
  50. // after, causing all updates to be ignored from then on
  51. //
  52. // 3. We can wrap everything in a project lock
  53. Metrics.inc('project_history_resync')
  54. try {
  55. await LockManager.promises.runWithLock(
  56. keys.projectHistoryLock({ project_id: projectId }),
  57. async extendLock => {
  58. await startResyncWithoutLock(projectId, options)
  59. }
  60. )
  61. } catch (error) {
  62. // record error in starting sync ("sync ongoing")
  63. if (error instanceof Error) {
  64. await ErrorRecorder.promises.record(projectId, -1, error)
  65. }
  66. throw error
  67. }
  68. }
  69. async function startHardResync(projectId, options = {}) {
  70. Metrics.inc('project_history_hard_resync')
  71. try {
  72. await LockManager.promises.runWithLock(
  73. keys.projectHistoryLock({ project_id: projectId }),
  74. async extendLock => {
  75. await clearResyncState(projectId)
  76. await RedisManager.promises.clearFirstOpTimestamp(projectId)
  77. await RedisManager.promises.destroyDocUpdatesQueue(projectId)
  78. await startResyncWithoutLock(projectId, options)
  79. }
  80. )
  81. } catch (error) {
  82. // record error in starting sync ("sync ongoing")
  83. if (error instanceof Error) {
  84. await ErrorRecorder.promises.record(projectId, -1, error)
  85. }
  86. throw error
  87. }
  88. }
  89. // The caller must hold the lock and should record any errors via the ErrorRecorder.
  90. async function startResyncWithoutLock(projectId, options) {
  91. await ErrorRecorder.promises.recordSyncStart(projectId)
  92. const syncState = await getResyncState(projectId)
  93. if (syncState.isSyncOngoing()) {
  94. if (syncState.isSyncStuck()) {
  95. const stuckDocPaths = Array.from(syncState.resyncDocContents)
  96. const stuckClearCount = syncState.stuckClearCount
  97. if (stuckClearCount >= MAX_STUCK_CLEAR_ATTEMPTS) {
  98. await _recordStuckClearInSyncState(projectId, stuckDocPaths)
  99. Metrics.inc('project_history_sync_stuck_permanent')
  100. // Log error only on first permanent-stuck detection to avoid spam
  101. if (stuckClearCount === MAX_STUCK_CLEAR_ATTEMPTS) {
  102. logger.error(
  103. {
  104. projectId,
  105. stuckClearCount: stuckClearCount + 1,
  106. stuckDocPaths,
  107. resyncPendingSince: syncState.resyncPendingSince,
  108. },
  109. 'sync permanently stuck — exceeded auto-clear limit'
  110. )
  111. }
  112. throw new OError('sync permanently stuck')
  113. }
  114. logger.warn(
  115. {
  116. projectId,
  117. stuckClearCount: stuckClearCount + 1,
  118. stuckDocPaths,
  119. resyncPendingSince: syncState.resyncPendingSince,
  120. },
  121. 'sync stuck, clearing state and restarting'
  122. )
  123. Metrics.inc('project_history_sync_stuck_cleared')
  124. await _recordStuckClearInSyncState(projectId, stuckDocPaths)
  125. } else {
  126. throw new OError(SYNC_ONGOING_ERROR_MESSAGE)
  127. }
  128. }
  129. syncState.setOrigin(options.origin || { kind: 'history-resync' })
  130. syncState.startProjectStructureSync()
  131. const webOpts = {}
  132. if (options.historyRangesMigration) {
  133. webOpts.historyRangesMigration = options.historyRangesMigration
  134. }
  135. if (options.resyncProjectStructureOnly) {
  136. webOpts.resyncProjectStructureOnly = options.resyncProjectStructureOnly
  137. }
  138. await WebApiManager.promises.requestResync(projectId, webOpts)
  139. await setResyncState(projectId, syncState)
  140. }
  141. /**
  142. * @param {string} sourceProjectId
  143. * @param {string} targetProjectId
  144. * @return {Promise<void>}
  145. */
  146. async function cloneResyncState(sourceProjectId, targetProjectId) {
  147. const rawSyncState = await db.projectHistorySyncState.findOne(
  148. { project_id: new ObjectId(sourceProjectId) },
  149. { projection: { _id: 0, project_id: 0 } }
  150. )
  151. if (!rawSyncState) return
  152. await db.projectHistorySyncState.insertOne({
  153. ...rawSyncState,
  154. project_id: new ObjectId(targetProjectId),
  155. })
  156. }
  157. /**
  158. * @param {string} projectId
  159. * @return {Promise<SyncState>}
  160. */
  161. async function getResyncState(projectId) {
  162. const rawSyncState = await db.projectHistorySyncState.findOne({
  163. project_id: new ObjectId(projectId.toString()),
  164. })
  165. const syncState = SyncState.fromRaw(projectId, rawSyncState)
  166. return syncState
  167. }
  168. async function setResyncState(projectId, syncState) {
  169. // skip if syncState is null (i.e. unchanged)
  170. if (syncState == null) {
  171. return
  172. }
  173. const update = {
  174. $set: syncState.toRaw(),
  175. $push: {
  176. history: {
  177. $each: [{ syncState: syncState.toRaw(), timestamp: new Date() }],
  178. $position: 0,
  179. $slice: MAX_RESYNC_HISTORY_RECORDS,
  180. },
  181. },
  182. $currentDate: { lastUpdated: true },
  183. }
  184. // handle different cases
  185. if (syncState.isSyncOngoing()) {
  186. // starting a new sync; prevent the entry expiring while sync is in ongoing
  187. update.$inc = { resyncCount: 1 }
  188. update.$unset = { expiresAt: true }
  189. update.$min = { resyncPendingSince: new Date() }
  190. } else {
  191. // successful completion of existing sync; set the entry to expire in the
  192. // future
  193. update.$set.expiresAt = new Date(
  194. Date.now() + EXPIRE_RESYNC_HISTORY_INTERVAL_MS
  195. )
  196. update.$unset = {
  197. resyncPendingSince: 1,
  198. stuckClearCount: 1,
  199. lastStuckClearAt: 1,
  200. lastStuckDocPaths: 1,
  201. }
  202. }
  203. // apply the update
  204. await db.projectHistorySyncState.updateOne(
  205. { project_id: new ObjectId(projectId) },
  206. update,
  207. { upsert: true }
  208. )
  209. }
  210. async function clearResyncState(projectId) {
  211. await db.projectHistorySyncState.deleteOne({
  212. project_id: new ObjectId(projectId.toString()),
  213. })
  214. }
  215. async function _recordStuckClearInSyncState(projectId, stuckDocPaths) {
  216. await db.projectHistorySyncState.updateOne(
  217. { project_id: new ObjectId(projectId) },
  218. {
  219. $inc: { stuckClearCount: 1 },
  220. $set: { lastStuckClearAt: new Date(), lastStuckDocPaths: stuckDocPaths },
  221. $unset: { resyncPendingSince: 1 },
  222. }
  223. )
  224. }
  225. /**
  226. * @param {string} projectId
  227. * @param {Date} date
  228. * @return {Promise<void>}
  229. */
  230. async function clearResyncStateIfAllAfter(projectId, date) {
  231. const rawSyncState = await db.projectHistorySyncState.findOne({
  232. project_id: new ObjectId(projectId.toString()),
  233. })
  234. if (!rawSyncState) return // already cleared
  235. const state = SyncState.fromRaw(projectId, rawSyncState)
  236. if (state.isSyncOngoing()) return // new sync started
  237. for (const { timestamp } of rawSyncState.history) {
  238. if (timestamp < date) return // preserve old resync states
  239. }
  240. // expiresAt is cleared when starting a sync and bumped when making changes.
  241. // Use expiresAt as read to ensure we only clear the confirmed state.
  242. await db.projectHistorySyncState.deleteOne({
  243. project_id: new ObjectId(projectId.toString()),
  244. expiresAt: rawSyncState.expiresAt,
  245. })
  246. }
  247. async function skipUpdatesDuringSync(projectId, updates) {
  248. const syncState = await getResyncState(projectId)
  249. if (!syncState.isSyncOngoing()) {
  250. logger.debug({ projectId }, 'not skipping updates: no resync in progress')
  251. // don't return syncState when unchanged
  252. return { updates, syncState: null }
  253. }
  254. const filteredUpdates = []
  255. for (const update of updates) {
  256. syncState.updateState(update)
  257. const shouldSkipUpdate = syncState.shouldSkipUpdate(update)
  258. if (!shouldSkipUpdate) {
  259. filteredUpdates.push(update)
  260. } else {
  261. Metrics.inc('project_history_sync_update_skipped')
  262. logger.debug({ projectId, update }, 'skipping update due to resync')
  263. }
  264. }
  265. return { updates: filteredUpdates, syncState }
  266. }
  267. /**
  268. * @param {string} projectId
  269. * @param {string} projectHistoryId
  270. * @param {{chunk: import('overleaf-editor-core/lib/types.js').RawChunk}} mostRecentChunk
  271. * @param {Array<Update>} updates
  272. * @param {() => Promise<void>} extendLock
  273. * @return {Promise<Array<Update>>}
  274. */
  275. async function expandSyncUpdates(
  276. projectId,
  277. projectHistoryId,
  278. mostRecentChunk,
  279. updates,
  280. extendLock
  281. ) {
  282. const areSyncUpdatesQueued =
  283. _.some(updates, 'resyncProjectStructure') ||
  284. _.some(updates, 'resyncDocContent')
  285. if (!areSyncUpdatesQueued) {
  286. logger.debug({ projectId }, 'no resync updates to expand')
  287. return updates
  288. }
  289. const syncState = await getResyncState(projectId)
  290. // compute the current snapshot from the most recent chunk
  291. const snapshotFiles =
  292. await SnapshotManager.promises.getLatestSnapshotFilesForChunk(
  293. projectHistoryId,
  294. mostRecentChunk
  295. )
  296. // check if snapshot files are valid
  297. const invalidFiles = _.pickBy(
  298. snapshotFiles,
  299. (v, k) => v == null || typeof v.isEditable !== 'function'
  300. )
  301. if (_.size(invalidFiles) > 0) {
  302. throw new SyncError('file is missing isEditable method', {
  303. projectId,
  304. invalidFiles,
  305. })
  306. }
  307. const expander = new SyncUpdateExpander(
  308. projectId,
  309. snapshotFiles,
  310. syncState.origin
  311. )
  312. // expand updates asynchronously to avoid blocking
  313. for (const update of updates) {
  314. await expander.expandUpdate(update)
  315. await extendLock()
  316. }
  317. return expander.getExpandedUpdates()
  318. }
  319. class SyncState {
  320. constructor(
  321. projectId,
  322. resyncProjectStructure,
  323. resyncDocContents,
  324. origin,
  325. resyncCount,
  326. resyncPendingSince,
  327. lastUpdated,
  328. history,
  329. stuckClearCount,
  330. lastStuckClearAt,
  331. lastStuckDocPaths
  332. ) {
  333. this.projectId = projectId
  334. this.resyncProjectStructure = resyncProjectStructure
  335. this.resyncDocContents = resyncDocContents
  336. this.origin = origin
  337. this.resyncCount = resyncCount
  338. this.resyncPendingSince = resyncPendingSince
  339. this.lastUpdated = lastUpdated
  340. this.history = history
  341. this.stuckClearCount = stuckClearCount
  342. this.lastStuckClearAt = lastStuckClearAt
  343. this.lastStuckDocPaths = lastStuckDocPaths
  344. }
  345. static fromRaw(projectId, rawSyncState) {
  346. rawSyncState = rawSyncState || {}
  347. const resyncProjectStructure = rawSyncState.resyncProjectStructure || false
  348. const resyncDocContents = new Set(rawSyncState.resyncDocContents || [])
  349. const origin = rawSyncState.origin
  350. const resyncCount = rawSyncState.resyncCount || 0
  351. let resyncPendingSince = rawSyncState.resyncPendingSince
  352. const history = rawSyncState.history || []
  353. if (
  354. (resyncProjectStructure || resyncDocContents.size > 0) &&
  355. !resyncPendingSince &&
  356. history.length > 0
  357. ) {
  358. // The resyncPendingSince field was added later.
  359. // Back-fill it as the next ts after a successful sync. History is DESC.
  360. for (const other of history.slice().reverse()) {
  361. const isSyncOngoing =
  362. other.syncState.resyncProjectStructure ||
  363. other.syncState.resyncDocContents.length > 0
  364. if (isSyncOngoing) {
  365. resyncPendingSince = resyncPendingSince || other.timestamp
  366. } else {
  367. resyncPendingSince = undefined
  368. }
  369. }
  370. }
  371. const lastUpdated = rawSyncState.lastUpdated
  372. const stuckClearCount = rawSyncState.stuckClearCount ?? 0
  373. const lastStuckClearAt = rawSyncState.lastStuckClearAt
  374. const lastStuckDocPaths = rawSyncState.lastStuckDocPaths
  375. return new SyncState(
  376. projectId,
  377. resyncProjectStructure,
  378. resyncDocContents,
  379. origin,
  380. resyncCount,
  381. resyncPendingSince,
  382. lastUpdated,
  383. history,
  384. stuckClearCount,
  385. lastStuckClearAt,
  386. lastStuckDocPaths
  387. )
  388. }
  389. toRaw() {
  390. return {
  391. resyncProjectStructure: this.resyncProjectStructure,
  392. resyncDocContents: Array.from(this.resyncDocContents),
  393. origin: this.origin,
  394. }
  395. }
  396. updateState(update) {
  397. if (update.resyncProjectStructure != null) {
  398. if (!this.isProjectStructureSyncing()) {
  399. throw new SyncError('unexpected resyncProjectStructure update', {
  400. projectId: this.projectId,
  401. resyncProjectStructure: this.resyncProjectStructure,
  402. })
  403. }
  404. if (this.isAnyDocContentSyncing()) {
  405. throw new SyncError('unexpected resyncDocContents update', {
  406. projectId: this.projectId,
  407. resyncDocContents: this.resyncDocContents,
  408. })
  409. }
  410. if (!update.resyncProjectStructureOnly) {
  411. for (const doc of update.resyncProjectStructure.docs) {
  412. this.startDocContentSync(doc.path)
  413. }
  414. }
  415. this.stopProjectStructureSync()
  416. } else if (update.resyncDocContent != null) {
  417. if (this.isProjectStructureSyncing()) {
  418. throw new SyncError('unexpected resyncDocContent update', {
  419. projectId: this.projectId,
  420. resyncProjectStructure: this.resyncProjectStructure,
  421. })
  422. }
  423. if (!this.isDocContentSyncing(update.path)) {
  424. throw new SyncError('unexpected resyncDocContent update', {
  425. projectId: this.projectId,
  426. resyncDocContents: this.resyncDocContents,
  427. path: update.path,
  428. })
  429. }
  430. this.stopDocContentSync(update.path)
  431. }
  432. }
  433. setOrigin(origin) {
  434. this.origin = origin
  435. }
  436. shouldSkipUpdate(update) {
  437. // don't skip sync updates
  438. if (
  439. update.resyncProjectStructure != null ||
  440. update.resyncDocContent != null
  441. ) {
  442. return false
  443. }
  444. // if syncing project structure skip update
  445. if (this.isProjectStructureSyncing()) {
  446. return true
  447. }
  448. // skip text updates for a docs being synced
  449. if (UpdateTranslator.isTextUpdate(update)) {
  450. if (this.isDocContentSyncing(update.meta.pathname)) {
  451. return true
  452. }
  453. }
  454. // preserve all other updates
  455. return false
  456. }
  457. startProjectStructureSync() {
  458. this.resyncProjectStructure = true
  459. this.resyncDocContents = new Set([])
  460. }
  461. stopProjectStructureSync() {
  462. this.resyncProjectStructure = false
  463. }
  464. stopDocContentSync(pathname) {
  465. this.resyncDocContents.delete(pathname)
  466. }
  467. startDocContentSync(pathname) {
  468. this.resyncDocContents.add(pathname)
  469. }
  470. isProjectStructureSyncing() {
  471. return this.resyncProjectStructure
  472. }
  473. isDocContentSyncing(pathname) {
  474. return this.resyncDocContents.has(pathname)
  475. }
  476. isAnyDocContentSyncing() {
  477. return this.resyncDocContents.size > 0
  478. }
  479. isSyncOngoing() {
  480. return this.isProjectStructureSyncing() || this.isAnyDocContentSyncing()
  481. }
  482. isSyncStuck() {
  483. if (!this.isSyncOngoing()) {
  484. return false
  485. }
  486. if (!this.resyncPendingSince) {
  487. // No timestamp recorded — treat long-running syncs without a timestamp
  488. // as potentially stuck (legacy state from before this field was added)
  489. return true
  490. }
  491. return (
  492. Date.now() - this.resyncPendingSince.getTime() > SYNC_STUCK_TIMEOUT_MS
  493. )
  494. }
  495. }
  496. class SyncUpdateExpander {
  497. /**
  498. * Build a SyncUpdateExpander
  499. *
  500. * @param {string} projectId
  501. * @param {Record<string, File>} snapshotFiles
  502. * @param {string} origin
  503. */
  504. constructor(projectId, snapshotFiles, origin) {
  505. this.projectId = projectId
  506. this.files = snapshotFiles
  507. this.expandedUpdates = /** @type ProjectStructureUpdate[] */ []
  508. this.origin = origin
  509. }
  510. // If there's an expected *file* with the same path and either the same hash
  511. // or no hash, treat this as not editable even if history thinks it is.
  512. isEditable(filePath, file, expectedFiles) {
  513. if (!file.isEditable()) {
  514. return false
  515. }
  516. const fileHash = _.get(file, ['data', 'hash'])
  517. const matchedExpectedFile = expectedFiles.some(item => {
  518. const expectedFileHash = item._hash
  519. if (expectedFileHash && fileHash !== expectedFileHash) {
  520. // expected file has a hash and it doesn't match
  521. return false
  522. }
  523. return UpdateTranslator._convertPathname(item.path) === filePath
  524. })
  525. // consider editable file in history as binary, since it matches a binary file in the project
  526. return !matchedExpectedFile
  527. }
  528. /**
  529. * @param {Update} update
  530. */
  531. async expandUpdate(update) {
  532. if ('resyncProjectStructure' in update) {
  533. logger.debug(
  534. { projectId: this.projectId, update },
  535. 'expanding resyncProjectStructure update'
  536. )
  537. const persistedNonBinaryFileEntries = _.pickBy(this.files, (v, k) =>
  538. this.isEditable(k, v, update.resyncProjectStructure.files)
  539. )
  540. const persistedNonBinaryFiles = _.map(
  541. Object.keys(persistedNonBinaryFileEntries),
  542. path => ({
  543. path,
  544. })
  545. )
  546. const persistedBinaryFileEntries = _.omitBy(this.files, (v, k) =>
  547. this.isEditable(k, v, update.resyncProjectStructure.files)
  548. )
  549. // preserve file properties on binary files, for future comparison.
  550. const persistedBinaryFiles = _.map(
  551. persistedBinaryFileEntries,
  552. (entity, key) => Object.assign({}, entity, { path: key })
  553. )
  554. const expectedNonBinaryFiles = _.map(
  555. update.resyncProjectStructure.docs,
  556. entity =>
  557. Object.assign({}, entity, {
  558. path: UpdateTranslator._convertPathname(entity.path),
  559. })
  560. )
  561. const expectedBinaryFiles = _.map(
  562. update.resyncProjectStructure.files,
  563. entity =>
  564. Object.assign({}, entity, {
  565. path: UpdateTranslator._convertPathname(entity.path),
  566. })
  567. )
  568. // We need to detect and fix consistency issues where web and
  569. // history-store disagree on whether an entity is binary or not. Thus we
  570. // need to remove and add the two separately.
  571. this.queueRemoveOpsForUnexpectedFiles(
  572. update,
  573. expectedBinaryFiles,
  574. persistedBinaryFiles
  575. )
  576. this.queueRemoveOpsForUnexpectedFiles(
  577. update,
  578. expectedNonBinaryFiles,
  579. persistedNonBinaryFiles
  580. )
  581. this.queueAddOpsForMissingFiles(
  582. update,
  583. expectedBinaryFiles,
  584. persistedBinaryFiles
  585. )
  586. this.queueAddOpsForMissingFiles(
  587. update,
  588. expectedNonBinaryFiles,
  589. persistedNonBinaryFiles
  590. )
  591. this.queueUpdateForOutOfSyncBinaryFiles(
  592. update,
  593. expectedBinaryFiles,
  594. persistedBinaryFiles
  595. )
  596. this.queueSetMetadataOpsForLinkedFiles(update)
  597. if (update.resyncProjectStructureOnly) {
  598. const docPaths = new Set()
  599. for (const entity of update.resyncProjectStructure.docs) {
  600. const path = UpdateTranslator._convertPathname(entity.path)
  601. docPaths.add(path)
  602. }
  603. for (const expandedUpdate of this.expandedUpdates) {
  604. if (docPaths.has(expandedUpdate.pathname)) {
  605. // Clear the resync state and queue entry, we need to start over.
  606. this.expandedUpdates = []
  607. await clearResyncState(this.projectId)
  608. await RedisManager.promises.deleteAppliedDocUpdate(
  609. this.projectId,
  610. update
  611. )
  612. throw new NeedFullProjectStructureResyncError(
  613. 'aborting partial resync: touched doc'
  614. )
  615. }
  616. }
  617. }
  618. } else if ('resyncDocContent' in update) {
  619. logger.debug(
  620. { projectId: this.projectId, update },
  621. 'expanding resyncDocContent update'
  622. )
  623. await this.expandResyncDocContentUpdate(update)
  624. } else {
  625. this.expandedUpdates.push(update)
  626. }
  627. }
  628. getExpandedUpdates() {
  629. return this.expandedUpdates
  630. }
  631. /**
  632. * @param {Entity[]} expectedFiles
  633. * @param {{ path: string }[]} persistedFiles
  634. */
  635. queueRemoveOpsForUnexpectedFiles(update, expectedFiles, persistedFiles) {
  636. const unexpectedFiles = _.differenceBy(
  637. persistedFiles,
  638. expectedFiles,
  639. 'path'
  640. )
  641. for (const entity of unexpectedFiles) {
  642. update = {
  643. pathname: entity.path,
  644. new_pathname: '',
  645. meta: {
  646. resync: true,
  647. origin: this.origin,
  648. ts: update.meta.ts,
  649. },
  650. }
  651. this.expandedUpdates.push(update)
  652. Metrics.inc('project_history_resync_operation', 1, {
  653. status: 'remove unexpected file',
  654. })
  655. }
  656. }
  657. /**
  658. * @param {Entity[]} expectedFiles
  659. * @param {{ path: string }[]} persistedFiles
  660. */
  661. queueAddOpsForMissingFiles(update, expectedFiles, persistedFiles) {
  662. const missingFiles = _.differenceBy(expectedFiles, persistedFiles, 'path')
  663. for (const entity of missingFiles) {
  664. update = {
  665. pathname: entity.path,
  666. meta: {
  667. resync: true,
  668. origin: this.origin,
  669. ts: update.meta.ts,
  670. },
  671. }
  672. if ('doc' in entity) {
  673. update.doc = entity.doc
  674. update.docLines = ''
  675. // we have to create a dummy entry here because later we will need the content in the diff computation
  676. this.files[update.pathname] = File.fromString('')
  677. } else {
  678. update.file = entity.file
  679. if (entity.url) update.url = entity.url
  680. if (entity._hash) update.hash = entity._hash
  681. if (entity.createdBlob) update.createdBlob = entity.createdBlob
  682. if (entity.metadata) update.metadata = entity.metadata
  683. }
  684. this.expandedUpdates.push(update)
  685. Metrics.inc('project_history_resync_operation', 1, {
  686. status: 'add missing file',
  687. })
  688. }
  689. }
  690. queueSetMetadataOpsForLinkedFiles(update) {
  691. const allEntities = update.resyncProjectStructure.docs.concat(
  692. update.resyncProjectStructure.files
  693. )
  694. for (const file of allEntities) {
  695. const pathname = UpdateTranslator._convertPathname(file.path)
  696. const matchingAddFileOperation = this.expandedUpdates.some(
  697. // Look for an addFile operation that already syncs the metadata.
  698. u => u.pathname === pathname && u.metadata === file.metadata
  699. )
  700. if (matchingAddFileOperation) continue
  701. const metaData = this.files[pathname].getMetadata()
  702. let shouldUpdate = false
  703. if (file.metadata) {
  704. // check for in place update of linked-file
  705. shouldUpdate = Object.entries(file.metadata).some(
  706. ([k, v]) => metaData[k] !== v
  707. )
  708. } else if (metaData.provider) {
  709. // overwritten by non-linked-file with same hash
  710. // or overwritten by doc
  711. shouldUpdate = true
  712. }
  713. if (!shouldUpdate) continue
  714. this.expandedUpdates.push({
  715. pathname,
  716. meta: {
  717. resync: true,
  718. origin: this.origin,
  719. ts: update.meta.ts,
  720. },
  721. metadata: file.metadata || {},
  722. })
  723. Metrics.inc('project_history_resync_operation', 1, {
  724. status: 'update metadata',
  725. })
  726. }
  727. }
  728. queueUpdateForOutOfSyncBinaryFiles(update, expectedFiles, persistedFiles) {
  729. // create a map to lookup persisted files by their path
  730. const persistedFileMap = new Map(persistedFiles.map(x => [x.path, x]))
  731. // now search for files with same path but different hash values
  732. const differentFiles = expectedFiles.filter(expected => {
  733. // check for a persisted file at the same path
  734. const expectedPath = expected.path
  735. const persistedFileAtSamePath = persistedFileMap.get(expectedPath)
  736. if (!persistedFileAtSamePath) return false
  737. // check if the persisted file at the same path has a different hash
  738. const expectedHash = _.get(expected, '_hash')
  739. const persistedHash = _.get(persistedFileAtSamePath, ['data', 'hash'])
  740. const hashesPresent = expectedHash && persistedHash
  741. return hashesPresent && persistedHash !== expectedHash
  742. })
  743. for (const entity of differentFiles) {
  744. // remove the outdated persisted file
  745. const removeUpdate = {
  746. pathname: entity.path,
  747. new_pathname: '',
  748. meta: {
  749. resync: true,
  750. origin: this.origin,
  751. ts: update.meta.ts,
  752. },
  753. }
  754. this.expandedUpdates.push(removeUpdate)
  755. // add the new file content
  756. const addUpdate = {
  757. pathname: entity.path,
  758. meta: {
  759. resync: true,
  760. origin: this.origin,
  761. ts: update.meta.ts,
  762. },
  763. file: entity.file,
  764. }
  765. if (entity.url) addUpdate.url = entity.url
  766. if (entity._hash) addUpdate.hash = entity._hash
  767. if (entity.createdBlob) addUpdate.createdBlob = entity.createdBlob
  768. if (entity.metadata) addUpdate.metadata = entity.metadata
  769. this.expandedUpdates.push(addUpdate)
  770. Metrics.inc('project_history_resync_operation', 1, {
  771. status: 'update binary file contents',
  772. })
  773. }
  774. }
  775. /**
  776. * Expand a resyncDocContentUpdate
  777. *
  778. * @param {ResyncDocContentUpdate} update
  779. */
  780. async expandResyncDocContentUpdate(update) {
  781. const pathname = UpdateTranslator._convertPathname(update.path)
  782. const snapshotFile = this.files[pathname]
  783. const expectedFile = update.resyncDocContent
  784. const expectedContent = expectedFile.content
  785. if (!snapshotFile) {
  786. throw new OError('unrecognised file: not in snapshot')
  787. }
  788. // Compare hashes to see if the persisted file matches the expected content.
  789. // The hash of the persisted files is stored in the snapshot.
  790. // Note getHash() returns the hash only when the persisted file has
  791. // no changes in the snapshot, the hash is null if there are changes
  792. // that apply to it.
  793. let hashesMatch = false
  794. const persistedHash = snapshotFile.getHash()
  795. if (persistedHash != null) {
  796. const expectedHash = HashManager._getBlobHashFromString(expectedContent)
  797. if (persistedHash === expectedHash) {
  798. logger.debug(
  799. { projectId: this.projectId, persistedHash, expectedHash },
  800. 'skipping diff because hashes match and persisted file has no ops'
  801. )
  802. hashesMatch = true
  803. }
  804. } else {
  805. logger.debug('cannot compare hashes, will retrieve content')
  806. }
  807. // compute the difference between the expected and persisted content
  808. const historyId = await WebApiManager.promises.getHistoryId(this.projectId)
  809. const file = await snapshotFile.load(
  810. 'eager',
  811. HistoryStoreManager.getBlobStore(historyId)
  812. )
  813. const persistedContent = file.getContent()
  814. if (persistedContent == null) {
  815. // This should not happen given that we loaded the file eagerly. We could
  816. // probably refine the types in overleaf-editor-core so that this check
  817. // wouldn't be necessary.
  818. throw new Error('File was not properly loaded')
  819. }
  820. if (!hashesMatch) {
  821. const expandedUpdate = await this.queueUpdateForOutOfSyncContent(
  822. update,
  823. pathname,
  824. persistedContent,
  825. expectedContent
  826. )
  827. if (expandedUpdate != null) {
  828. // Adjust the ranges for the changes that have been made to the content
  829. for (const op of expandedUpdate.op) {
  830. if (isInsert(op)) {
  831. file.getComments().applyInsert(new Range(op.p, op.i.length))
  832. file.getTrackedChanges().applyInsert(op.p, op.i)
  833. } else if (isDelete(op)) {
  834. file.getComments().applyDelete(new Range(op.p, op.d.length))
  835. file.getTrackedChanges().applyDelete(op.p, op.d.length)
  836. }
  837. }
  838. }
  839. }
  840. const persistedComments = file.getComments().toArray()
  841. if (update.resyncDocContent.historyOTRanges) {
  842. this.queueUpdatesForOutOfSyncCommentsHistoryOT(
  843. update,
  844. pathname,
  845. file.getComments().toRaw()
  846. )
  847. } else {
  848. await this.queueUpdatesForOutOfSyncComments(
  849. update,
  850. pathname,
  851. persistedComments
  852. )
  853. }
  854. const persistedChanges = file.getTrackedChanges().asSorted()
  855. await this.queueUpdatesForOutOfSyncTrackedChanges(
  856. update,
  857. pathname,
  858. persistedChanges
  859. )
  860. }
  861. /**
  862. * Queue update for out of sync content
  863. *
  864. * @param {ResyncDocContentUpdate} update
  865. * @param {string} pathname
  866. * @param {string} persistedContent
  867. * @param {string} expectedContent
  868. */
  869. async queueUpdateForOutOfSyncContent(
  870. update,
  871. pathname,
  872. persistedContent,
  873. expectedContent
  874. ) {
  875. logger.debug(
  876. { projectId: this.projectId, persistedContent, expectedContent },
  877. 'diffing doc contents'
  878. )
  879. const op = UpdateCompressor.diffAsShareJsOps(
  880. persistedContent,
  881. expectedContent
  882. )
  883. if (op.length === 0) {
  884. return null
  885. }
  886. const expandedUpdate = {
  887. doc: update.doc,
  888. op,
  889. meta: {
  890. resync: true,
  891. origin: this.origin,
  892. ts: update.meta.ts,
  893. pathname,
  894. doc_length: persistedContent.length,
  895. },
  896. }
  897. logger.debug(
  898. { projectId: this.projectId, diffCount: op.length },
  899. 'doc contents differ'
  900. )
  901. this.expandedUpdates.push(expandedUpdate)
  902. Metrics.inc('project_history_resync_operation', 1, {
  903. status: 'update text file contents',
  904. })
  905. return expandedUpdate
  906. }
  907. /**
  908. * Queue updates for out of sync comments
  909. *
  910. * @param {ResyncDocContentUpdate} update
  911. * @param {string} pathname
  912. * @param {CommentRawData[]} persistedComments
  913. */
  914. queueUpdatesForOutOfSyncCommentsHistoryOT(
  915. update,
  916. pathname,
  917. persistedComments
  918. ) {
  919. const expectedComments =
  920. update.resyncDocContent.historyOTRanges?.comments ?? []
  921. const expectedCommentsById = new Map(
  922. expectedComments.map(comment => [comment.id, comment])
  923. )
  924. const persistedCommentsById = new Map(
  925. persistedComments.map(comment => [comment.id, comment])
  926. )
  927. // Delete any persisted comment that is not in the expected comment list.
  928. for (const persistedComment of persistedComments) {
  929. if (!expectedCommentsById.has(persistedComment.id)) {
  930. this.expandedUpdates.push({
  931. doc: update.doc,
  932. op: [{ deleteComment: persistedComment.id }],
  933. meta: {
  934. pathname,
  935. resync: true,
  936. origin: this.origin,
  937. ts: update.meta.ts,
  938. },
  939. })
  940. }
  941. }
  942. for (const expectedComment of expectedComments) {
  943. const persistedComment = persistedCommentsById.get(expectedComment.id)
  944. if (
  945. persistedComment &&
  946. commentRangesAreInSyncHistoryOT(persistedComment, expectedComment)
  947. ) {
  948. if (expectedComment.resolved === persistedComment.resolved) {
  949. // Both comments are identical; do nothing
  950. } else {
  951. // Only the resolved state differs
  952. this.expandedUpdates.push({
  953. doc: update.doc,
  954. op: [
  955. {
  956. commentId: expectedComment.id,
  957. resolved: expectedComment.resolved,
  958. },
  959. ],
  960. meta: {
  961. pathname,
  962. resync: true,
  963. origin: this.origin,
  964. ts: update.meta.ts,
  965. },
  966. })
  967. }
  968. } else {
  969. // New comment or ranges differ
  970. this.expandedUpdates.push({
  971. doc: update.doc,
  972. op: [
  973. {
  974. commentId: expectedComment.id,
  975. ranges: expectedComment.ranges,
  976. resolved: expectedComment.resolved,
  977. },
  978. ],
  979. meta: {
  980. pathname,
  981. resync: true,
  982. origin: this.origin,
  983. ts: update.meta.ts,
  984. },
  985. })
  986. }
  987. }
  988. }
  989. /**
  990. * Queue updates for out of sync comments
  991. *
  992. * @param {ResyncDocContentUpdate} update
  993. * @param {string} pathname
  994. * @param {HistoryComment[]} persistedComments
  995. */
  996. async queueUpdatesForOutOfSyncComments(update, pathname, persistedComments) {
  997. const expectedContent = update.resyncDocContent.content
  998. const expectedComments = update.resyncDocContent.ranges?.comments ?? []
  999. const resolvedCommentIds = new Set(
  1000. update.resyncDocContent.resolvedCommentIds ?? []
  1001. )
  1002. const expectedCommentsById = new Map(
  1003. expectedComments.map(comment => [comment.id, comment])
  1004. )
  1005. const persistedCommentsById = new Map(
  1006. persistedComments.map(comment => [comment.id, comment])
  1007. )
  1008. // Delete any persisted comment that is not in the expected comment list.
  1009. for (const persistedComment of persistedComments) {
  1010. if (!expectedCommentsById.has(persistedComment.id)) {
  1011. this.expandedUpdates.push({
  1012. pathname,
  1013. deleteComment: persistedComment.id,
  1014. meta: {
  1015. resync: true,
  1016. origin: this.origin,
  1017. ts: update.meta.ts,
  1018. },
  1019. })
  1020. }
  1021. }
  1022. for (const expectedComment of expectedComments) {
  1023. const persistedComment = persistedCommentsById.get(expectedComment.id)
  1024. const expectedCommentResolved = resolvedCommentIds.has(expectedComment.id)
  1025. if (
  1026. persistedComment != null &&
  1027. commentRangesAreInSync(persistedComment, expectedComment)
  1028. ) {
  1029. if (expectedCommentResolved === persistedComment.resolved) {
  1030. // Both comments are identical; do nothing
  1031. } else {
  1032. // Only the resolved state differs
  1033. this.expandedUpdates.push({
  1034. pathname,
  1035. commentId: expectedComment.id,
  1036. resolved: expectedCommentResolved,
  1037. meta: {
  1038. resync: true,
  1039. origin: this.origin,
  1040. ts: update.meta.ts,
  1041. },
  1042. })
  1043. }
  1044. } else {
  1045. const op = { ...expectedComment.op, resolved: expectedCommentResolved }
  1046. // New comment or ranges differ
  1047. this.expandedUpdates.push({
  1048. doc: update.doc,
  1049. op: [op],
  1050. meta: {
  1051. resync: true,
  1052. origin: this.origin,
  1053. ts: update.meta.ts,
  1054. pathname,
  1055. doc_length: expectedContent.length,
  1056. },
  1057. })
  1058. }
  1059. }
  1060. }
  1061. /**
  1062. * Queue updates for out of sync tracked changes
  1063. *
  1064. * @param {ResyncDocContentUpdate} update
  1065. * @param {string} pathname
  1066. * @param {readonly HistoryTrackedChange[]} persistedChanges
  1067. */
  1068. async queueUpdatesForOutOfSyncTrackedChanges(
  1069. update,
  1070. pathname,
  1071. persistedChanges
  1072. ) {
  1073. const expectedChanges = update.resyncDocContent.ranges?.changes ?? []
  1074. const expectedContent = update.resyncDocContent.content
  1075. /**
  1076. * A cursor on the expected content
  1077. */
  1078. let cursor = 0
  1079. /**
  1080. * The persisted tracking at cursor
  1081. *
  1082. * @type {TrackingDirective}
  1083. */
  1084. let persistedTracking = { type: 'none' }
  1085. /**
  1086. * The expected tracking at cursor
  1087. *
  1088. * @type {TrackingDirective}
  1089. */
  1090. let expectedTracking = { type: 'none' }
  1091. /**
  1092. * The retain ops for the update
  1093. *
  1094. * @type {RetainOp[]}
  1095. */
  1096. const ops = []
  1097. /**
  1098. * The retain op being built
  1099. *
  1100. * @type {RetainOp | null}
  1101. */
  1102. let currentOp = null
  1103. for (const transition of getTrackedChangesTransitions(
  1104. persistedChanges,
  1105. expectedChanges,
  1106. update.resyncDocContent.historyOTRanges?.trackedChanges || [],
  1107. expectedContent.length
  1108. )) {
  1109. if (transition.pos > cursor) {
  1110. // The next transition will move the cursor. Decide what to do with the interval.
  1111. if (trackingDirectivesEqual(expectedTracking, persistedTracking)) {
  1112. // Expected tracking and persisted tracking are in sync. Emit the
  1113. // current op and skip this interval.
  1114. if (currentOp != null) {
  1115. ops.push(currentOp)
  1116. currentOp = null
  1117. }
  1118. } else {
  1119. // Expected tracking and persisted tracking are different.
  1120. const retainedText = expectedContent.slice(cursor, transition.pos)
  1121. if (
  1122. currentOp?.tracking != null &&
  1123. trackingDirectivesEqual(expectedTracking, currentOp.tracking)
  1124. ) {
  1125. // The current op has the right tracking. Extend it.
  1126. currentOp.r += retainedText
  1127. } else {
  1128. // The current op doesn't have the right tracking. Emit the current
  1129. // op and start a new one.
  1130. if (currentOp != null) {
  1131. ops.push(currentOp)
  1132. }
  1133. currentOp = {
  1134. r: retainedText,
  1135. p: cursor,
  1136. tracking: expectedTracking,
  1137. }
  1138. }
  1139. }
  1140. // Advance cursor
  1141. cursor = transition.pos
  1142. }
  1143. // Update the expected and persisted tracking
  1144. if (transition.stage === 'persisted') {
  1145. persistedTracking = transition.tracking
  1146. } else {
  1147. expectedTracking = transition.tracking
  1148. }
  1149. }
  1150. // Emit the last op
  1151. if (currentOp != null) {
  1152. ops.push(currentOp)
  1153. }
  1154. if (ops.length > 0) {
  1155. this.expandedUpdates.push({
  1156. doc: update.doc,
  1157. op: ops,
  1158. meta: {
  1159. resync: true,
  1160. origin: this.origin,
  1161. ts: update.meta.ts,
  1162. pathname,
  1163. doc_length: expectedContent.length,
  1164. },
  1165. })
  1166. }
  1167. }
  1168. }
  1169. /**
  1170. * Compares the ranges in the persisted and expected comments
  1171. *
  1172. * @param {CommentRawData} persistedComment
  1173. * @param {CommentRawData} expectedComment
  1174. */
  1175. function commentRangesAreInSyncHistoryOT(persistedComment, expectedComment) {
  1176. if (persistedComment.ranges.length !== expectedComment.ranges.length) {
  1177. return false
  1178. }
  1179. for (let i = 0; i < persistedComment.ranges.length; i++) {
  1180. const persistedRange = persistedComment.ranges[i]
  1181. const expectedRange = expectedComment.ranges[i]
  1182. if (persistedRange.pos !== expectedRange.pos) return false
  1183. if (persistedRange.length !== expectedRange.length) return false
  1184. }
  1185. return true
  1186. }
  1187. /**
  1188. * Compares the ranges in the persisted and expected comments
  1189. *
  1190. * @param {HistoryComment} persistedComment
  1191. * @param {Comment} expectedComment
  1192. */
  1193. function commentRangesAreInSync(persistedComment, expectedComment) {
  1194. const expectedPos = expectedComment.op.hpos ?? expectedComment.op.p
  1195. const expectedLength = expectedComment.op.hlen ?? expectedComment.op.c.length
  1196. if (expectedLength === 0) {
  1197. // A zero length comment from RangesManager is a detached comment in history
  1198. return persistedComment.ranges.length === 0
  1199. }
  1200. if (persistedComment.ranges.length !== 1) {
  1201. // The editor only supports single range comments
  1202. return false
  1203. }
  1204. const persistedRange = persistedComment.ranges[0]
  1205. return (
  1206. persistedRange.pos === expectedPos &&
  1207. persistedRange.length === expectedLength
  1208. )
  1209. }
  1210. /**
  1211. * Iterates through expected tracked changes and persisted tracked changes and
  1212. * returns all transitions, sorted by position.
  1213. *
  1214. * @param {readonly HistoryTrackedChange[]} persistedChanges
  1215. * @param {TrackedChange[]} expectedChanges
  1216. * @param {TrackedChangeRawData[]} persistedChangesHistoryOT
  1217. * @param {number} docLength
  1218. */
  1219. function getTrackedChangesTransitions(
  1220. persistedChanges,
  1221. expectedChanges,
  1222. persistedChangesHistoryOT,
  1223. docLength
  1224. ) {
  1225. /** @type {TrackedChangeTransition[]} */
  1226. const transitions = []
  1227. for (const change of persistedChanges) {
  1228. transitions.push({
  1229. stage: 'persisted',
  1230. pos: change.range.start,
  1231. tracking: {
  1232. type: change.tracking.type,
  1233. userId: change.tracking.userId,
  1234. ts: change.tracking.ts.toISOString(),
  1235. },
  1236. })
  1237. transitions.push({
  1238. stage: 'persisted',
  1239. pos: change.range.end,
  1240. tracking: { type: 'none' },
  1241. })
  1242. }
  1243. for (const change of persistedChangesHistoryOT) {
  1244. transitions.push({
  1245. stage: 'expected',
  1246. pos: change.range.pos,
  1247. tracking: change.tracking,
  1248. })
  1249. transitions.push({
  1250. stage: 'expected',
  1251. pos: change.range.pos + change.range.length,
  1252. tracking: { type: 'none' },
  1253. })
  1254. }
  1255. for (const change of expectedChanges) {
  1256. const op = change.op
  1257. const pos = op.hpos ?? op.p
  1258. if (isInsert(op)) {
  1259. transitions.push({
  1260. stage: 'expected',
  1261. pos,
  1262. tracking: {
  1263. type: 'insert',
  1264. userId: change.metadata.user_id,
  1265. ts: change.metadata.ts,
  1266. },
  1267. })
  1268. transitions.push({
  1269. stage: 'expected',
  1270. pos: pos + op.i.length,
  1271. tracking: { type: 'none' },
  1272. })
  1273. } else {
  1274. transitions.push({
  1275. stage: 'expected',
  1276. pos,
  1277. tracking: {
  1278. type: 'delete',
  1279. userId: change.metadata.user_id,
  1280. ts: change.metadata.ts,
  1281. },
  1282. })
  1283. transitions.push({
  1284. stage: 'expected',
  1285. pos: pos + op.d.length,
  1286. tracking: { type: 'none' },
  1287. })
  1288. }
  1289. }
  1290. transitions.push({
  1291. stage: 'expected',
  1292. pos: docLength,
  1293. tracking: { type: 'none' },
  1294. })
  1295. transitions.sort((a, b) => {
  1296. if (a.pos < b.pos) {
  1297. return -1
  1298. } else if (a.pos > b.pos) {
  1299. return 1
  1300. } else if (a.tracking.type === 'none' && b.tracking.type !== 'none') {
  1301. // none type comes before other types so that it can be overridden at the
  1302. // same position
  1303. return -1
  1304. } else if (a.tracking.type !== 'none' && b.tracking.type === 'none') {
  1305. // none type comes before other types so that it can be overridden at the
  1306. // same position
  1307. return 1
  1308. } else {
  1309. return 0
  1310. }
  1311. })
  1312. return transitions
  1313. }
  1314. /**
  1315. * Returns true if both tracking directives are equal
  1316. *
  1317. * @param {TrackingDirective} a
  1318. * @param {TrackingDirective} b
  1319. */
  1320. function trackingDirectivesEqual(a, b) {
  1321. if (a.type === 'none') {
  1322. return b.type === 'none'
  1323. } else {
  1324. return a.type === b.type && a.userId === b.userId && a.ts === b.ts
  1325. }
  1326. }
  1327. // EXPORTS
  1328. const cloneResyncStateCb = callbackify(cloneResyncState)
  1329. const getResyncStateCb = callbackify(getResyncState)
  1330. const startResyncCb = callbackify(startResync)
  1331. const startResyncWithoutLockCb = callbackify(startResyncWithoutLock)
  1332. const startHardResyncCb = callbackify(startHardResync)
  1333. const setResyncStateCb = callbackify(setResyncState)
  1334. const clearResyncStateCb = callbackify(clearResyncState)
  1335. const skipUpdatesDuringSyncCb = callbackifyMultiResult(skipUpdatesDuringSync, [
  1336. 'updates',
  1337. 'syncState',
  1338. ])
  1339. /**
  1340. * @param {string} projectId
  1341. * @param {string} projectHistoryId
  1342. * @param {{chunk: import('overleaf-editor-core/lib/types.js').RawChunk}} mostRecentChunk
  1343. * @param {Array<Update>} updates
  1344. * @param {() => void} extendLock
  1345. * @param {(err: Error | null, updates?: Array<Update>) => void} callback
  1346. */
  1347. const expandSyncUpdatesCb = (
  1348. projectId,
  1349. projectHistoryId,
  1350. mostRecentChunk,
  1351. updates,
  1352. extendLock,
  1353. callback
  1354. ) => {
  1355. const extendLockPromises = promisify(extendLock)
  1356. expandSyncUpdates(
  1357. projectId,
  1358. projectHistoryId,
  1359. mostRecentChunk,
  1360. updates,
  1361. extendLockPromises
  1362. )
  1363. .then(result => {
  1364. callback(null, result)
  1365. })
  1366. .catch(err => {
  1367. callback(err)
  1368. })
  1369. }
  1370. export {
  1371. cloneResyncStateCb as cloneResyncState,
  1372. getResyncStateCb as getResyncState,
  1373. startResyncCb as startResync,
  1374. startResyncWithoutLockCb as startResyncWithoutLock,
  1375. startHardResyncCb as startHardResync,
  1376. setResyncStateCb as setResyncState,
  1377. clearResyncStateCb as clearResyncState,
  1378. skipUpdatesDuringSyncCb as skipUpdatesDuringSync,
  1379. expandSyncUpdatesCb as expandSyncUpdates,
  1380. }
  1381. export const promises = {
  1382. cloneResyncState,
  1383. getResyncState,
  1384. startResync,
  1385. startResyncWithoutLock,
  1386. startHardResync,
  1387. setResyncState,
  1388. clearResyncState,
  1389. clearResyncStateIfAllAfter,
  1390. skipUpdatesDuringSync,
  1391. expandSyncUpdates,
  1392. }