SyncManager.js 42 KB

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