SyncManager.js 45 KB

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