SyncManager.js 41 KB

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