diff --git a/src/__tests__/saveDocs.test.ts b/src/__tests__/saveDocs.test.ts new file mode 100644 index 000000000..77e0d59ed --- /dev/null +++ b/src/__tests__/saveDocs.test.ts @@ -0,0 +1,224 @@ +import algoliasearch from 'algoliasearch'; + +import { saveDocs, saveDoc } from '../saveDocs'; + +import preact from './preact-simplified.json'; + +it('should be similar batch vs one', async () => { + const client = algoliasearch('e', ''); + const index = client.initIndex('a'); + let batch; + let single; + jest.spyOn(index, 'saveObjects').mockImplementationOnce((val) => { + batch = val[0]; + return true as any; + }); + jest.spyOn(index, 'saveObject').mockImplementationOnce((val) => { + single = val; + return true as any; + }); + const final = { + _searchInternal: { + alternativeNames: ['preact', 'preact.js', 'preactjs'], + downloadsMagnitude: 7, + expiresAt: '2021-08-10', + jsDelivrPopularity: 0, + }, + bin: {}, + changelogFilename: null, + computedKeywords: [], + computedMetadata: {}, + created: 1441939293521, + dependencies: {}, + dependents: 0, + deprecated: false, + deprecatedReason: null, + description: + 'Fast 3kb React alternative with the same modern API. Components & Virtual DOM.', + devDependencies: { + '@types/chai': '^4.1.7', + '@types/mocha': '^5.2.5', + '@types/node': '^9.6.40', + 'babel-cli': '^6.24.1', + 'babel-core': '^6.24.1', + 'babel-eslint': '^8.2.6', + 'babel-loader': '^7.0.0', + 'babel-plugin-transform-object-rest-spread': '^6.23.0', + 'babel-plugin-transform-react-jsx': '^6.24.1', + 'babel-preset-env': '^1.6.1', + bundlesize: '^0.17.0', + chai: '^4.2.0', + copyfiles: '^2.1.0', + 'core-js': '^2.6.0', + coveralls: '^3.0.0', + 'cross-env': '^5.1.4', + diff: '^3.0.0', + eslint: '^4.18.2', + 'eslint-plugin-react': '^7.11.1', + 'flow-bin': '^0.89.0', + 'gzip-size-cli': '^2.0.0', + 'istanbul-instrumenter-loader': '^3.0.0', + jscodeshift: '^0.5.0', + karma: '^3.1.3', + 'karma-babel-preprocessor': '^7.0.0', + 'karma-chai-sinon': '^0.1.5', + 'karma-chrome-launcher': '^2.2.0', + 'karma-coverage': '^1.1.2', + 'karma-mocha': '^1.3.0', + 'karma-mocha-reporter': '^2.2.5', + 'karma-sauce-launcher': '^1.2.0', + 'karma-sinon': '^1.0.5', + 'karma-source-map-support': '^1.3.0', + 'karma-sourcemap-loader': '^0.3.6', + 'karma-webpack': '^3.0.5', + mocha: '^5.0.4', + 'npm-run-all': '^4.1.5', + puppeteer: '^1.11.0', + rimraf: '^2.5.3', + rollup: '^0.57.1', + 'rollup-plugin-babel': '^3.0.2', + 'rollup-plugin-memory': '^3.0.0', + 'rollup-plugin-node-resolve': '^3.4.0', + sinon: '^4.4.2', + 'sinon-chai': '^3.3.0', + typescript: '^3.0.1', + 'uglify-js': '^2.7.5', + webpack: '^4.27.1', + }, + downloadsLast30Days: 2874638, + downloadsRatio: 0.0023, + gitHead: 'master', + githubRepo: { + head: 'master', + path: '', + project: 'preact', + user: 'developit', + }, + homepage: null, + humanDependents: '0', + humanDownloadsLast30Days: '2.9m', + isDeprecated: false, + jsDelivrHits: 0, + keywords: [ + 'preact', + 'react', + 'virtual dom', + 'vdom', + 'components', + 'virtual', + 'dom', + ], + lastCrawl: '2021-07-11T12:31:18.112Z', + lastPublisher: { + avatar: 'https://gravatar.com/avatar/ad82ff1463f3e3b7b4a44c5f499912ae', + email: 'npm.leah@hrmny.sh', + link: 'https://www.npmjs.com/~harmony', + name: 'harmony', + }, + license: 'MIT', + modified: 1564778088321, + moduleTypes: ['esm'], + name: 'preact', + objectID: 'preact', + originalAuthor: { + email: 'jason@developit.ca', + name: 'Jason Miller', + }, + owner: { + avatar: 'https://github.com/developit.png', + link: 'https://github.com/developit', + name: 'developit', + }, + owners: [ + { + avatar: 'https://gravatar.com/avatar/85ed8e6da2fbf39abeb4995189be324c', + email: 'jason@developit.ca', + link: 'https://www.npmjs.com/~developit', + name: 'developit', + }, + { + avatar: 'https://gravatar.com/avatar/52401c37bc5c4d54a051c619767fdbf8', + email: 'ulliftw@gmail.com', + link: 'https://www.npmjs.com/~harmony', + name: 'harmony', + }, + { + avatar: 'https://gravatar.com/avatar/308439e12701ef85245dc0632dd07c2a', + email: 'luke@lukeed.com', + link: 'https://www.npmjs.com/~lukeed', + name: 'lukeed', + }, + { + avatar: 'https://gravatar.com/avatar/4ed639a3ea6219b80b58e2e81ff9ba47', + email: 'marvin@marvinhagemeister.de', + link: 'https://www.npmjs.com/~marvinhagemeister', + name: 'marvinhagemeister', + }, + { + avatar: 'https://gravatar.com/avatar/83589d88ac76ddc2853562f9a817fe27', + email: 'prateek89born@gmail.com', + link: 'https://www.npmjs.com/~prateekbh', + name: 'prateekbh', + }, + { + avatar: 'https://gravatar.com/avatar/88747cce15801e9e96bcb76895fcd7f9', + email: 'hello@preactjs.com', + link: 'https://www.npmjs.com/~preactjs', + name: 'preactjs', + }, + { + avatar: 'https://gravatar.com/avatar/d279821c96bb49eeaef68b5456f42074', + email: 'allamsetty.anup@gmail.com', + link: 'https://www.npmjs.com/~reznord', + name: 'reznord', + }, + ], + popular: false, + readme: '', + repository: { + branch: 'master', + head: undefined, + host: 'github.com', + path: '', + project: 'preact', + type: 'git', + url: 'https://github.com/developit/preact', + user: 'developit', + }, + tags: { + latest: '8.5.0', + next: '10.0.0-rc.1', + }, + types: { + ts: 'included', + }, + version: '8.5.0', + versions: { + '10.0.0-rc.1': '2019-08-02T20:34:45.123Z', + '8.5.0': '2019-08-02T18:34:23.572Z', + }, + }; + const clean = expect.objectContaining({ + ...final, + lastCrawl: expect.any(String), + downloadsLast30Days: expect.any(Number), + downloadsRatio: expect.any(Number), + humanDownloadsLast30Days: expect.any(String), + modified: expect.any(Number), + _searchInternal: expect.objectContaining({ + downloadsMagnitude: expect.any(Number), + expiresAt: expect.any(String), + }), + }); + + const row = { id: '', key: 'preact', value: { rev: 'a' }, doc: preact }; + await saveDocs({ docs: [row], index }); + await saveDoc({ row, index }); + + expect(index.saveObjects).toHaveBeenCalledWith([clean]); + expect(index.saveObject).toHaveBeenCalledWith(clean); + expect(single).toMatchObject({ + ...batch, + lastCrawl: expect.any(String), + }); +}); diff --git a/src/bootstrap.ts b/src/bootstrap.ts index b074a70a8..f5f5b5cc6 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -6,7 +6,7 @@ import type { StateManager } from './StateManager'; import * as algolia from './algolia'; import { config } from './config'; import * as npm from './npm'; -import saveDocs from './saveDocs'; +import { saveDocs } from './saveDocs'; import { datadog } from './utils/datadog'; import { log } from './utils/log'; diff --git a/src/changelog.ts b/src/changelog.ts index 7e87f3eb2..401c3b13f 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -100,50 +100,55 @@ export async function getChangelog( ): Promise<{ changelogFilename: string | null; }> { - for (const file of filelist) { - const name = path.basename(file.name); - if (!fileRegex.test(name)) { - // eslint-disable-next-line no-continue - continue; - } + const start = Date.now(); + try { + for (const file of filelist) { + const name = path.basename(file.name); + if (!fileRegex.test(name)) { + // eslint-disable-next-line no-continue + continue; + } - datadog.increment('jsdelivr.getChangelog.hit'); + datadog.increment('jsdelivr.getChangelog.hit'); - return { changelogFilename: jsDelivr.getFullURL(pkg, file) }; - } + return { changelogFilename: jsDelivr.getFullURL(pkg, file) }; + } - datadog.increment('jsdelivr.getChangelog.miss'); + datadog.increment('jsdelivr.getChangelog.miss'); - const { repository, name, version } = pkg; + const { repository, name, version } = pkg; - // Rollback to brute-force the source code - const unpkgFiles = fileOptions.map( - (file) => `${config.unpkgRoot}/${name}@${version}/${file}` - ); + // Rollback to brute-force the source code + const unpkgFiles = fileOptions.map( + (file) => `${config.unpkgRoot}/${name}@${version}/${file}` + ); - if (repository === null) { - return await raceFromPaths(unpkgFiles); - } + if (repository === null) { + return await raceFromPaths(unpkgFiles); + } - const user = repository.user || ''; - const project = repository.project || ''; - const host = repository.host || ''; - if (user.length < 1 || project.length < 1) { - return await raceFromPaths(unpkgFiles); - } + const user = repository.user || ''; + const project = repository.project || ''; + const host = repository.host || ''; + if (user.length < 1 || project.length < 1) { + return await raceFromPaths(unpkgFiles); + } - // Check if we know how to handle this host - if (!baseUrlMap.has(host)) { - return await raceFromPaths(unpkgFiles); - } + // Check if we know how to handle this host + if (!baseUrlMap.has(host)) { + return await raceFromPaths(unpkgFiles); + } - const baseUrl = baseUrlMap.get(host)!(repository); + const baseUrl = baseUrlMap.get(host)!(repository); - const files = fileOptions.map((file) => - [baseUrl.replace(/\/$/, ''), file].join('/') - ); + const files = fileOptions.map((file) => + [baseUrl.replace(/\/$/, ''), file].join('/') + ); - return await raceFromPaths([...files, ...unpkgFiles]); + return await raceFromPaths([...files, ...unpkgFiles]); + } finally { + datadog.timing('changelogs.getChangelog', Date.now() - start); + } } export async function getChangelogs( diff --git a/src/jsDelivr/index.ts b/src/jsDelivr/index.ts index d4648e434..883a4d056 100644 --- a/src/jsDelivr/index.ts +++ b/src/jsDelivr/index.ts @@ -6,6 +6,10 @@ import { request } from '../utils/request'; type Hit = { type: 'npm'; name: string; hits: number }; export type File = { name: string; hash: string; time: string; size: number }; +type GetHit = { + jsDelivrHits: number; + _searchInternal: { jsDelivrPopularity: number }; +}; export const hits = new Map(); @@ -35,28 +39,27 @@ export async function loadHits(): Promise { /** * Get download hits. */ -export function getHits(pkgs: Array>): Array<{ - jsDelivrHits: number; - _searchInternal: { jsDelivrPopularity: number }; -}> { +export function getHits(pkgs: Array>): GetHit[] { const start = Date.now(); - const all = pkgs.map(({ name }) => { - const jsDelivrHits = hits.get(name) || 0; - - return { - jsDelivrHits, - _searchInternal: { - // anything below 1000 hits/month is likely to mean that - // someone just made a few random requests so we count that as 0 - jsDelivrPopularity: Math.max(jsDelivrHits.toString().length - 3, 0), - }, - }; - }); + const all = pkgs.map(getHit); datadog.timing('jsdelivr.getHits', Date.now() - start); return all; } +export function getHit(pkg: Pick): GetHit { + const jsDelivrHits = hits.get(pkg.name) || 0; + + return { + jsDelivrHits, + _searchInternal: { + // anything below 1000 hits/month is likely to mean that + // someone just made a few random requests so we count that as 0 + jsDelivrPopularity: Math.max(jsDelivrHits.toString().length - 3, 0), + }, + }; +} + /** * Get packages files list. */ diff --git a/src/npm/__tests__/index.test.ts b/src/npm/__tests__/index.test.ts index 7249fd1e0..50d53e327 100644 --- a/src/npm/__tests__/index.test.ts +++ b/src/npm/__tests__/index.test.ts @@ -102,9 +102,9 @@ describe('getDependents()', () => { }); }); -describe('getDownload()', () => { +describe('fetchDownload()', () => { it('should download one package and return correct response', async () => { - const dl = await api.getDownload('jest'); + const dl = await api.fetchDownload('jest'); expect(dl.body).toHaveProperty('jest'); expect(dl.body.jest).toEqual({ downloads: expect.any(Number), @@ -115,7 +115,7 @@ describe('getDownload()', () => { }); it('should download one scoped package and return correct response', async () => { - const dl = await api.getDownload('@angular/core'); + const dl = await api.fetchDownload('@angular/core'); expect(dl.body).toHaveProperty('@angular/core'); expect(dl.body['@angular/core']).toEqual({ downloads: expect.any(Number), @@ -126,7 +126,7 @@ describe('getDownload()', () => { }); it('should download 2 packages and return correct response', async () => { - const dl = await api.getDownload('jest,holmes.js'); + const dl = await api.fetchDownload('jest,holmes.js'); expect(dl.body).toHaveProperty('jest'); expect(dl.body).toHaveProperty(['holmes.js']); }); diff --git a/src/npm/index.ts b/src/npm/index.ts index ddfd8ae30..cc1cb8168 100644 --- a/src/npm/index.ts +++ b/src/npm/index.ts @@ -18,6 +18,19 @@ import { httpsAgent, request, USER_AGENT } from '../utils/request'; import type { GetInfo, GetPackage, PackageDownload } from './types'; +type GetDependent = { dependents: number; humanDependents: string }; +type GetDownload = { + downloadsLast30Days: number; + humanDownloadsLast30Days: string; + downloadsRatio: number; + popular: boolean; + _searchInternal: { + expiresAt?: string; + popularName?: string; + downloadsMagnitude: number; + }; +}; + const registry = nano({ url: config.npmRegistryEndpoint, requestDefaults: { @@ -161,13 +174,13 @@ async function validatePackageExists(pkgName: string): Promise { */ function getDependents( pkgs: Array> -): Promise> { +): Promise { // we return 0, waiting for https://github.com/npm/registry/issues/361 - return Promise.all( - pkgs.map(() => { - return { dependents: 0, humanDependents: '0' }; - }) - ); + return Promise.all(pkgs.map(getDependent)); +} + +function getDependent(_pkg: Pick): GetDependent { + return { dependents: 0, humanDependents: '0' }; } /** @@ -192,9 +205,11 @@ async function getTotalDownloads(): Promise { /** * Get download stats for a list of packages. */ -async function getDownload( +async function fetchDownload( pkgNames: string ): Promise<{ body: Record }> { + const start = Date.now(); + try { const response = await request< Record | (PackageDownload | null) @@ -217,25 +232,55 @@ async function getDownload( } catch (error) { log.warn(`An error ocurred when getting download of ${pkgNames} ${error}`); return { body: {} }; + } finally { + datadog.timing('npm.fetchDownload', Date.now() - start); } } +function computeDownload( + pkg: Pick, + downloads: PackageDownload | null, + totalNpmDownloads: number +): GetDownload | null { + if (!downloads) { + return null; + } + + const downloadsLast30Days = downloads.downloads; + const downloadsRatio = Number( + ((downloadsLast30Days / totalNpmDownloads) * 100).toFixed(4) + ); + const popular = downloadsRatio > config.popularDownloadsRatio; + const downloadsMagnitude = downloadsLast30Days + ? downloadsLast30Days.toString().length + : 0; + + return { + downloadsLast30Days, + humanDownloadsLast30Days: numeral(downloadsLast30Days).format('0.[0]a'), + downloadsRatio, + popular, + _searchInternal: { + // if the package is popular, we copy its name to a dedicated attribute + // which will make popular records' `name` matches to be ranked higher than other matches + // see the `searchableAttributes` index setting + ...(popular && { + popularName: pkg.name, + expiresAt: new Date(Date.now() + config.popularExpiresAt) + .toISOString() + .split('T')[0], + }), + downloadsMagnitude, + }, + }; +} + /** * Get downloads for all packages passer in arguments. */ -async function getDownloads(pkgs: Array>): Promise< - Array<{ - downloadsLast30Days: number; - humanDownloadsLast30Days: string; - downloadsRatio: number; - popular: boolean; - _searchInternal: { - expiresAt?: string; - popularName?: string; - downloadsMagnitude: number; - }; - } | null> -> { +async function getDownloads( + pkgs: Array> +): Promise> { const start = Date.now(); // npm has a weird API to get downloads via GET params, so we split pkgs into chunks @@ -258,8 +303,8 @@ async function getDownloads(pkgs: Array>): Promise< const totalNpmDownloads = await getTotalDownloads(); const downloadsPerPkgNameChunks = await Promise.all([ - ...pkgsNamesChunks.map(getDownload), - ...encodedScopedPackageNames.map(getDownload), + ...pkgsNamesChunks.map(fetchDownload), + ...encodedScopedPackageNames.map(fetchDownload), ]); const downloadsPerPkgName: Record = @@ -271,46 +316,33 @@ async function getDownloads(pkgs: Array>): Promise< {} ); - const all = pkgs.map(({ name }) => { - if (downloadsPerPkgName[name] === undefined) { - return null; - } - - const downloadsLast30Days = downloadsPerPkgName[name] - ? downloadsPerPkgName[name].downloads - : 0; - const downloadsRatio = Number( - ((downloadsLast30Days / totalNpmDownloads) * 100).toFixed(4) + const all = pkgs.map((pkg) => { + return computeDownload( + pkg, + downloadsPerPkgName[pkg.name], + totalNpmDownloads ); - const popular = downloadsRatio > config.popularDownloadsRatio; - const downloadsMagnitude = downloadsLast30Days - ? downloadsLast30Days.toString().length - : 0; - - return { - downloadsLast30Days, - humanDownloadsLast30Days: numeral(downloadsLast30Days).format('0.[0]a'), - downloadsRatio, - popular, - _searchInternal: { - // if the package is popular, we copy its name to a dedicated attribute - // which will make popular records' `name` matches to be ranked higher than other matches - // see the `searchableAttributes` index setting - ...(popular && { - popularName: name, - expiresAt: new Date(Date.now() + config.popularExpiresAt) - .toISOString() - .split('T')[0], - }), - downloadsMagnitude, - }, - }; }); datadog.timing('npm.getDownloads', Date.now() - start); return all; } +async function getDownload( + pkg: Pick +): Promise { + const start = Date.now(); + + try { + const name = encodeURIComponent(pkg.name); + const totalNpmDownloads = await getTotalDownloads(); + const downloads = await fetchDownload(name); + return computeDownload(pkg, downloads.body[pkg.name], totalNpmDownloads); + } finally { + datadog.timing('npm.getDownload', Date.now() - start); + } +} + export { findAll, listenToChanges, @@ -319,6 +351,8 @@ export { getDocs, validatePackageExists, getDependents, + getDependent, getDownload, + fetchDownload, getDownloads, }; diff --git a/src/saveDocs.ts b/src/saveDocs.ts index 37ec1df60..b863d9c05 100644 --- a/src/saveDocs.ts +++ b/src/saveDocs.ts @@ -2,16 +2,16 @@ import type { SearchIndex } from 'algoliasearch'; import type { DocumentResponseRow } from 'nano'; import type { FinalPkg, RawPkg } from './@types/pkg'; -import { getChangelogs } from './changelog'; +import { getChangelogs, getChangelog } from './changelog'; import formatPkg from './formatPkg'; import * as jsDelivr from './jsDelivr'; import * as npm from './npm'; import type { GetPackage } from './npm/types'; -import { getTSSupport } from './typescript/index'; +import { getTSSupport, getTypeScriptSupport } from './typescript/index'; import { datadog } from './utils/datadog'; import { log } from './utils/log'; -export default async function saveDocs({ +export async function saveDocs({ docs, index, }: { @@ -45,7 +45,7 @@ export default async function saveDocs({ log.info(' Adding metadata...'); let start2 = Date.now(); - const pkgs = await addMetaData(rawPkgs); + const pkgs = await addMetaDatas(rawPkgs); datadog.timing('saveDocs.addMetaData', Date.now() - start2); log.info(` Saving...`); @@ -60,7 +60,42 @@ export default async function saveDocs({ return pkgs.length; } -async function addMetaData(pkgs: RawPkg[]): Promise { +export async function saveDoc({ + row, + index, +}: { + row: DocumentResponseRow; + index: SearchIndex; +}): Promise { + const start = Date.now(); + + const formatted = formatPkg(row.doc!); + + datadog.timing('formatPkg', Date.now() - start); + + if (!formatted) { + return; + } + + log.info(' => ', formatted.name); + log.info(' Adding metadata...'); + + let start2 = Date.now(); + const pkg = await addMetaData(formatted); + datadog.timing('saveDocs.addMetaData.one', Date.now() - start2); + + log.info(` Saving...`); + + start2 = Date.now(); + await index.saveObject(pkg); + datadog.timing('saveDocs.saveObject.one', Date.now() - start2); + + log.info(` Saved`); + + datadog.timing('saveDocs.one', Date.now() - start); +} + +async function addMetaDatas(pkgs: RawPkg[]): Promise { const [downloads, dependents, hits, filelists] = await Promise.all([ npm.getDownloads(pkgs), npm.getDependents(pkgs), @@ -93,3 +128,35 @@ async function addMetaData(pkgs: RawPkg[]): Promise { datadog.timing('saveDocs.addMetaData', Date.now() - start); return all; } + +async function addMetaData(pkg: RawPkg): Promise { + const [download, dependent, hit, filelist] = await Promise.all([ + npm.getDownload(pkg), + npm.getDependent(pkg), + jsDelivr.getHit(pkg), + jsDelivr.getFilesList(pkg), + ]); + + const [changelog, ts] = await Promise.all([ + getChangelog(pkg, filelist), + getTypeScriptSupport(pkg, filelist), + ]); + + const start = Date.now(); + const final = { + ...pkg, + ...download, + ...dependent, + ...changelog, + ...hit, + ...ts, + _searchInternal: { + ...pkg._searchInternal, + ...(download ? download!._searchInternal : {}), + ...hit._searchInternal, + }, + }; + + datadog.timing('saveDocs.addMetaData.one', Date.now() - start); + return final; +} diff --git a/src/typescript/index.ts b/src/typescript/index.ts index 519ba7a12..6dcaecc83 100644 --- a/src/typescript/index.ts +++ b/src/typescript/index.ts @@ -58,35 +58,41 @@ export function getTypeScriptSupport( pkg: Pick, filelist: File[] ): Pick { - // Already calculated in `formatPkg` - if (pkg.types.ts === 'included') { - return { types: pkg.types }; - } + const start = Date.now(); - // The 2nd most likely is definitely typed - const defTyped = isDefinitelyTyped({ name: pkg.name }); - if (defTyped) { - return { - types: { - ts: 'definitely-typed', - definitelyTyped: `@types/${defTyped}`, - }, - }; - } + try { + // Already calculated in `formatPkg` + if (pkg.types.ts === 'included') { + return { types: pkg.types }; + } - for (const file of filelist) { - if (!file.name.endsWith('.d.ts')) { - // eslint-disable-next-line no-continue - continue; + // The 2nd most likely is definitely typed + const defTyped = isDefinitelyTyped({ name: pkg.name }); + if (defTyped) { + return { + types: { + ts: 'definitely-typed', + definitelyTyped: `@types/${defTyped}`, + }, + }; } - datadog.increment('jsdelivr.getTSSupport.hit'); + for (const file of filelist) { + if (!file.name.endsWith('.d.ts')) { + // eslint-disable-next-line no-continue + continue; + } - return { types: { ts: 'included' } }; - } - datadog.increment('jsdelivr.getTSSupport.miss'); + datadog.increment('jsdelivr.getTSSupport.hit'); + + return { types: { ts: 'included' } }; + } + datadog.increment('jsdelivr.getTSSupport.miss'); - return { types: { ts: false } }; + return { types: { ts: false } }; + } finally { + datadog.timing('typescript.getSupport', Date.now() - start); + } } /** diff --git a/src/watch.ts b/src/watch.ts index f846a34a0..285cccfe3 100644 --- a/src/watch.ts +++ b/src/watch.ts @@ -5,7 +5,7 @@ import type { DatabaseChangesResultItem, DocumentLookupFailure } from 'nano'; import type { StateManager } from './StateManager'; import * as npm from './npm'; -import saveDocs from './saveDocs'; +import { saveDocs } from './saveDocs'; import { datadog } from './utils/datadog'; import { log } from './utils/log'; import * as sentry from './utils/sentry';