DocstoreManager.mjs 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. // @ts-check
  2. import { callbackify, callbackifyMultiResult } from '@overleaf/promise-utils'
  3. import OError from '@overleaf/o-error'
  4. import logger from '@overleaf/logger'
  5. import settings from '@overleaf/settings'
  6. import Errors from '../Errors/Errors.js'
  7. import {
  8. fetchJson,
  9. fetchNothing,
  10. RequestFailedError,
  11. } from '@overleaf/fetch-utils'
  12. import path from 'node:path'
  13. /**
  14. * @import { ObjectId } from 'mongodb'
  15. */
  16. const TIMEOUT = 30 * 1000 // request timeout
  17. /**
  18. *
  19. * @param {string | ObjectId} projectId
  20. * @param {string | ObjectId} docId
  21. * @param {string} name
  22. * @param {Date} deletedAt
  23. * @return {Promise<void>}
  24. */
  25. async function deleteDoc(projectId, docId, name, deletedAt) {
  26. const url = new URL(settings.apis.docstore.url)
  27. url.pathname = path.posix.join(
  28. 'project',
  29. projectId.toString(),
  30. 'doc',
  31. docId.toString()
  32. )
  33. const docMetaData = { deleted: true, deletedAt, name }
  34. const options = {
  35. json: docMetaData,
  36. signal: AbortSignal.timeout(TIMEOUT),
  37. method: 'PATCH',
  38. }
  39. try {
  40. await fetchNothing(url, options)
  41. } catch (error) {
  42. if (error instanceof RequestFailedError) {
  43. if (error.response.status === 404) {
  44. // maybe suppress the error when delete doc which is not present?
  45. throw new Errors.NotFoundError({
  46. message: 'tried to delete doc not in docstore',
  47. info: {
  48. projectId,
  49. docId,
  50. },
  51. })
  52. }
  53. throw new OError(
  54. `docstore api responded with non-success code: ${error.response.status}`,
  55. {
  56. projectId,
  57. docId,
  58. }
  59. )
  60. }
  61. throw error
  62. }
  63. }
  64. /**
  65. * @param {string} projectId
  66. */
  67. async function getAllDocs(projectId) {
  68. const url = new URL(settings.apis.docstore.url)
  69. url.pathname = path.posix.join('project', projectId.toString(), 'doc')
  70. try {
  71. return await fetchJson(url, { signal: AbortSignal.timeout(TIMEOUT) })
  72. } catch (error) {
  73. if (error instanceof RequestFailedError) {
  74. throw new OError(
  75. `docstore api responded with non-success code: ${error.response.status}`,
  76. { projectId }
  77. )
  78. }
  79. throw error
  80. }
  81. }
  82. /**
  83. *
  84. * @param {string|ObjectId} projectId
  85. * @return {Promise<*>}
  86. */
  87. async function getAllDeletedDocs(projectId) {
  88. const url = new URL(settings.apis.docstore.url)
  89. url.pathname = path.posix.join('project', projectId.toString(), 'doc-deleted')
  90. try {
  91. return await fetchJson(url, { signal: AbortSignal.timeout(TIMEOUT) })
  92. } catch (error) {
  93. if (error instanceof RequestFailedError) {
  94. throw new OError(
  95. `docstore api responded with non-success code: ${error.response.status}`,
  96. { projectId }
  97. )
  98. }
  99. throw OError.tag(error, 'could not get deleted docs from docstore')
  100. }
  101. }
  102. /**
  103. * @param {string} projectId
  104. */
  105. async function getCommentThreadIds(projectId) {
  106. const url = `${settings.apis.docstore.url}/project/${projectId}/comment-thread-ids`
  107. return fetchJson(url, { signal: AbortSignal.timeout(TIMEOUT) })
  108. }
  109. /**
  110. * @param {string} projectId
  111. */
  112. async function getTrackedChangesUserIds(projectId) {
  113. const url = `${settings.apis.docstore.url}/project/${projectId}/tracked-changes-user-ids`
  114. return fetchJson(url, { signal: AbortSignal.timeout(TIMEOUT) })
  115. }
  116. /**
  117. * @param {string} projectId
  118. */
  119. async function getAllRanges(projectId) {
  120. const url = new URL(settings.apis.docstore.url)
  121. url.pathname = path.posix.join('project', projectId, 'ranges')
  122. try {
  123. return await fetchJson(url, { signal: AbortSignal.timeout(TIMEOUT) })
  124. } catch (error) {
  125. if (error instanceof RequestFailedError) {
  126. throw new OError(
  127. `docstore api responded with non-success code: ${error.response.status}`,
  128. { projectId }
  129. )
  130. }
  131. throw error
  132. }
  133. }
  134. /**
  135. *
  136. * @param {string | ObjectId} projectId
  137. * @param {string | ObjectId} docId
  138. * @param {{ peek?: boolean, include_deleted?: boolean }} options
  139. * @return {Promise<{lines: *, rev: *, version: *, ranges: *}>}
  140. */
  141. async function getDoc(projectId, docId, options = {}) {
  142. const url = new URL(settings.apis.docstore.url)
  143. if (options.peek) {
  144. url.pathname = path.posix.join(
  145. 'project',
  146. projectId.toString(),
  147. 'doc',
  148. docId.toString(),
  149. 'peek'
  150. )
  151. } else {
  152. url.pathname = path.posix.join(
  153. 'project',
  154. projectId.toString(),
  155. 'doc',
  156. docId.toString()
  157. )
  158. }
  159. if (options.include_deleted) {
  160. url.searchParams.set('include_deleted', 'true')
  161. }
  162. try {
  163. const doc = await fetchJson(url, { signal: AbortSignal.timeout(TIMEOUT) })
  164. logger.debug(
  165. { docId, projectId, version: doc.version, rev: doc.rev },
  166. 'got doc from docstore api'
  167. )
  168. return {
  169. lines: doc.lines,
  170. rev: doc.rev,
  171. version: doc.version,
  172. ranges: doc.ranges,
  173. }
  174. } catch (error) {
  175. if (error instanceof RequestFailedError) {
  176. if (error.response.status === 404) {
  177. throw new Errors.NotFoundError({
  178. message: 'doc not found in docstore',
  179. info: {
  180. projectId,
  181. docId,
  182. },
  183. })
  184. }
  185. throw new OError(
  186. `docstore api responded with non-success code: ${error.response.status}`,
  187. {
  188. projectId,
  189. docId,
  190. }
  191. )
  192. }
  193. throw error
  194. }
  195. }
  196. /**
  197. *
  198. * @param {string} projectId
  199. * @param {string} docId
  200. * @return {Promise<boolean>}
  201. */
  202. async function isDocDeleted(projectId, docId) {
  203. const url = new URL(settings.apis.docstore.url)
  204. url.pathname = path.posix.join('project', projectId, 'doc', docId, 'deleted')
  205. try {
  206. const doc = await fetchJson(url, { signal: AbortSignal.timeout(TIMEOUT) })
  207. return doc.deleted
  208. } catch (error) {
  209. if (error instanceof RequestFailedError) {
  210. if (error.response.status === 404) {
  211. throw new Errors.NotFoundError({
  212. message: 'doc does not exist in project',
  213. info: { projectId, docId },
  214. })
  215. }
  216. throw new OError(
  217. `docstore api responded with non-success code: ${error.response.status}`,
  218. { projectId, docId }
  219. )
  220. }
  221. throw error
  222. }
  223. }
  224. /**
  225. *
  226. * @param {string} projectId
  227. * @param {string} docId
  228. * @param {string[]} lines
  229. * @param {number} version
  230. * @param ranges
  231. * @return {Promise<{modified: *, rev: *}>}
  232. */
  233. async function updateDoc(projectId, docId, lines, version, ranges) {
  234. const url = new URL(settings.apis.docstore.url)
  235. url.pathname = path.posix.join('project', projectId, 'doc', docId)
  236. try {
  237. const result = await fetchJson(url, {
  238. method: 'POST',
  239. signal: AbortSignal.timeout(TIMEOUT),
  240. json: {
  241. lines,
  242. version,
  243. ranges,
  244. },
  245. })
  246. logger.debug({ projectId, docId }, 'update doc in docstore url finished')
  247. return { modified: result.modified, rev: result.rev }
  248. } catch (error) {
  249. if (error instanceof RequestFailedError) {
  250. throw new OError(
  251. `docstore api responded with non-success code: ${error.response.status}`,
  252. { projectId, docId }
  253. )
  254. }
  255. throw error
  256. }
  257. }
  258. /**
  259. * Asks docstore whether any doc in the project has ranges
  260. *
  261. * @param {string} projectId
  262. */
  263. async function projectHasRanges(projectId) {
  264. const url = new URL(settings.apis.docstore.url)
  265. url.pathname = path.posix.join('project', projectId, 'has-ranges')
  266. try {
  267. const body = await fetchJson(url, { signal: AbortSignal.timeout(TIMEOUT) })
  268. return body.projectHasRanges
  269. } catch (error) {
  270. if (error instanceof RequestFailedError) {
  271. throw new OError(
  272. `docstore api responded with non-success code: ${error.response.status}`,
  273. { projectId }
  274. )
  275. }
  276. throw error
  277. }
  278. }
  279. /**
  280. *
  281. * @param {string|ObjectId} projectId
  282. * @return {Promise<void>}
  283. */
  284. async function archiveProject(projectId) {
  285. await _operateOnProject(projectId, 'archive')
  286. }
  287. /**
  288. *
  289. * @param {string|ObjectId} projectId
  290. * @return {Promise<void>}
  291. */
  292. async function unarchiveProject(projectId) {
  293. await _operateOnProject(projectId, 'unarchive')
  294. }
  295. /**
  296. *
  297. * @param {string|ObjectId} projectId
  298. * @return {Promise<void>}
  299. */
  300. async function destroyProject(projectId) {
  301. await _operateOnProject(projectId, 'destroy')
  302. }
  303. /**
  304. *
  305. * @param {string|ObjectId} projectId
  306. * @param {string} method
  307. * @return {Promise<void>}
  308. * @private
  309. */
  310. async function _operateOnProject(projectId, method) {
  311. const url = new URL(settings.apis.docstore.url)
  312. url.pathname = path.posix.join('project', projectId.toString(), method)
  313. logger.debug({ projectId }, `calling ${method} for project in docstore`)
  314. try {
  315. // use default timeout for archiving/unarchiving/destroying
  316. await fetchNothing(url, {
  317. method: 'POST',
  318. })
  319. } catch (err) {
  320. if (err instanceof RequestFailedError) {
  321. const error = new Error(
  322. `docstore api responded with non-success code: ${err.response.status}`
  323. )
  324. logger.warn(
  325. { err: error, projectId },
  326. `error calling ${method} project in docstore`
  327. )
  328. throw error
  329. }
  330. throw OError.tag(err, `error calling ${method} project in docstore`, {
  331. projectId,
  332. })
  333. }
  334. }
  335. export default {
  336. deleteDoc: callbackify(deleteDoc),
  337. getAllDocs: callbackify(getAllDocs),
  338. getAllDeletedDocs: callbackify(getAllDeletedDocs),
  339. getAllRanges: callbackify(getAllRanges),
  340. getDoc: callbackifyMultiResult(getDoc, ['lines', 'rev', 'version', 'ranges']),
  341. getCommentThreadIds: callbackify(getCommentThreadIds),
  342. getTrackedChangesUserIds: callbackify(getTrackedChangesUserIds),
  343. isDocDeleted: callbackify(isDocDeleted),
  344. updateDoc: callbackifyMultiResult(updateDoc, ['modified', 'rev']),
  345. projectHasRanges: callbackify(projectHasRanges),
  346. archiveProject: callbackify(archiveProject),
  347. unarchiveProject: callbackify(unarchiveProject),
  348. destroyProject: callbackify(destroyProject),
  349. promises: {
  350. deleteDoc,
  351. getAllDocs,
  352. getAllDeletedDocs,
  353. getAllRanges,
  354. getDoc,
  355. getCommentThreadIds,
  356. getTrackedChangesUserIds,
  357. isDocDeleted,
  358. updateDoc,
  359. projectHasRanges,
  360. archiveProject,
  361. unarchiveProject,
  362. destroyProject,
  363. },
  364. }