@sanity/export 3.37.4 → 3.38.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -4
- package/src/AssetHandler.js +64 -46
- package/src/constants.js +22 -0
- package/src/export.js +142 -118
- package/src/getDocumentsStream.js +4 -4
- package/src/rejectOnApiError.js +7 -1
- package/src/requestStream.js +12 -6
- package/src/util/extractFirstError.js +14 -0
- package/src/validateOptions.js +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanity/export",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.38.1",
|
|
4
4
|
"description": "Export Sanity documents and assets",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sanity",
|
|
@@ -27,9 +27,10 @@
|
|
|
27
27
|
],
|
|
28
28
|
"scripts": {
|
|
29
29
|
"lint": "eslint .",
|
|
30
|
-
"test": "jest"
|
|
30
|
+
"test": "jest --verbose"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
+
"@sanity/client": "^6.15.20",
|
|
33
34
|
"@sanity/util": "3.37.2",
|
|
34
35
|
"archiver": "^7.0.0",
|
|
35
36
|
"debug": "^4.3.4",
|
|
@@ -38,7 +39,9 @@
|
|
|
38
39
|
"mississippi": "^4.0.0",
|
|
39
40
|
"p-queue": "^2.3.0",
|
|
40
41
|
"rimraf": "^3.0.2",
|
|
41
|
-
"split2": "^4.2.0"
|
|
42
|
+
"split2": "^4.2.0",
|
|
43
|
+
"tar": "^7.0.1",
|
|
44
|
+
"yaml": "^2.4.2"
|
|
42
45
|
},
|
|
43
46
|
"devDependencies": {
|
|
44
47
|
"@jest/globals": "^29.7.0",
|
|
@@ -48,9 +51,11 @@
|
|
|
48
51
|
"eslint-config-sanity": "^7.1.2",
|
|
49
52
|
"eslint-plugin-prettier": "^5.1.3",
|
|
50
53
|
"jest": "^29.7.0",
|
|
54
|
+
"nock": "^13.5.4",
|
|
51
55
|
"prettier": "^3.2.5",
|
|
52
56
|
"prettier-plugin-packagejson": "^2.5.0",
|
|
53
|
-
"string-to-stream": "^1.1.0"
|
|
57
|
+
"string-to-stream": "^1.1.0",
|
|
58
|
+
"tar": "^7.0.1"
|
|
54
59
|
},
|
|
55
60
|
"engines": {
|
|
56
61
|
"node": ">=18"
|
package/src/AssetHandler.js
CHANGED
|
@@ -9,29 +9,11 @@ const pkg = require('../package.json')
|
|
|
9
9
|
const debug = require('./debug')
|
|
10
10
|
const requestStream = require('./requestStream')
|
|
11
11
|
const rimraf = require('./util/rimraf')
|
|
12
|
+
const {ASSET_DOWNLOAD_MAX_RETRIES, ASSET_DOWNLOAD_CONCURRENCY} = require('./constants')
|
|
12
13
|
|
|
13
14
|
const EXCLUDE_PROPS = ['_id', '_type', 'assetId', 'extension', 'mimeType', 'path', 'url']
|
|
14
15
|
const ACTION_REMOVE = 'remove'
|
|
15
16
|
const ACTION_REWRITE = 'rewrite'
|
|
16
|
-
const ASSET_DOWNLOAD_CONCURRENCY = 8
|
|
17
|
-
|
|
18
|
-
const retryHelper = (times, fn, onError) => {
|
|
19
|
-
let attempt = 0
|
|
20
|
-
const caller = (...args) => {
|
|
21
|
-
return fn(...args).catch((err) => {
|
|
22
|
-
if (onError) {
|
|
23
|
-
onError(err, attempt)
|
|
24
|
-
}
|
|
25
|
-
if (attempt < times) {
|
|
26
|
-
attempt++
|
|
27
|
-
return caller(...args)
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
throw err
|
|
31
|
-
})
|
|
32
|
-
}
|
|
33
|
-
return caller
|
|
34
|
-
}
|
|
35
17
|
|
|
36
18
|
class AssetHandler {
|
|
37
19
|
constructor(options) {
|
|
@@ -47,6 +29,7 @@ class AssetHandler {
|
|
|
47
29
|
this.assetMap = {}
|
|
48
30
|
this.filesWritten = 0
|
|
49
31
|
this.queueSize = 0
|
|
32
|
+
this.maxRetries = options.maxRetries || ASSET_DOWNLOAD_MAX_RETRIES
|
|
50
33
|
this.queue = options.queue || new PQueue({concurrency})
|
|
51
34
|
|
|
52
35
|
this.rejectedError = null
|
|
@@ -123,26 +106,42 @@ class AssetHandler {
|
|
|
123
106
|
this.queueSize++
|
|
124
107
|
this.downloading.push(assetDoc.url)
|
|
125
108
|
|
|
126
|
-
const doDownload =
|
|
127
|
-
|
|
128
|
-
(
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
109
|
+
const doDownload = async () => {
|
|
110
|
+
let dlError
|
|
111
|
+
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
|
|
112
|
+
try {
|
|
113
|
+
return await this.downloadAsset(assetDoc, dstPath)
|
|
114
|
+
} catch (err) {
|
|
115
|
+
debug(
|
|
116
|
+
`Error downloading asset %s (destination: %s), attempt %d`,
|
|
117
|
+
assetDoc._id,
|
|
118
|
+
dstPath,
|
|
119
|
+
attempt,
|
|
120
|
+
err,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
dlError = err
|
|
124
|
+
|
|
125
|
+
if ('statusCode' in err && err.statusCode >= 400 && err.statusCode < 500) {
|
|
126
|
+
// Don't retry on client errors
|
|
127
|
+
break
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
throw dlError
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.queue
|
|
135
|
+
.add(() =>
|
|
136
|
+
doDownload().catch((err) => {
|
|
137
|
+
debug('Failed to download the asset, aborting download', err)
|
|
138
|
+
this.queue.clear()
|
|
139
|
+
this.reject(err)
|
|
140
|
+
}),
|
|
141
|
+
)
|
|
142
|
+
.catch((error) => {
|
|
143
|
+
debug('Queued task failed', error)
|
|
144
|
+
})
|
|
146
145
|
}
|
|
147
146
|
|
|
148
147
|
maybeCreateAssetDirs() {
|
|
@@ -163,7 +162,13 @@ class AssetHandler {
|
|
|
163
162
|
const isImage = assetDoc._type === 'sanity.imageAsset'
|
|
164
163
|
|
|
165
164
|
const url = parseUrl(assetDoc.url, true)
|
|
166
|
-
if (
|
|
165
|
+
if (
|
|
166
|
+
isImage &&
|
|
167
|
+
token &&
|
|
168
|
+
(['cdn.sanity.io', 'cdn.sanity.work'].includes(url.hostname) ||
|
|
169
|
+
// used in tests. use a very specific port to avoid conflicts
|
|
170
|
+
url.host === 'localhost:43216')
|
|
171
|
+
) {
|
|
167
172
|
headers.Authorization = `Bearer ${token}`
|
|
168
173
|
url.query = {...(url.query || {}), dlRaw: 'true'}
|
|
169
174
|
}
|
|
@@ -191,15 +196,13 @@ class AssetHandler {
|
|
|
191
196
|
}
|
|
192
197
|
|
|
193
198
|
if (stream.statusCode !== 200) {
|
|
194
|
-
|
|
199
|
+
let errMsg
|
|
195
200
|
try {
|
|
196
201
|
const err = await tryGetErrorFromStream(stream)
|
|
197
|
-
|
|
202
|
+
errMsg = `Referenced asset URL "${url}" returned HTTP ${stream.statusCode}`
|
|
198
203
|
if (err) {
|
|
199
|
-
errMsg = `${errMsg}
|
|
204
|
+
errMsg = `${errMsg}: ${err}`
|
|
200
205
|
}
|
|
201
|
-
|
|
202
|
-
throw new Error(errMsg)
|
|
203
206
|
} catch (err) {
|
|
204
207
|
const message = 'Failed to parse error response from asset stream'
|
|
205
208
|
if (typeof err.message === 'string') {
|
|
@@ -210,6 +213,10 @@ class AssetHandler {
|
|
|
210
213
|
|
|
211
214
|
throw new Error(message, {cause: err})
|
|
212
215
|
}
|
|
216
|
+
|
|
217
|
+
const streamError = new Error(errMsg)
|
|
218
|
+
streamError.statusCode = stream.statusCode
|
|
219
|
+
throw streamError
|
|
213
220
|
}
|
|
214
221
|
|
|
215
222
|
this.maybeCreateAssetDirs()
|
|
@@ -385,9 +392,20 @@ function writeHashedStream(filePath, stream) {
|
|
|
385
392
|
|
|
386
393
|
function tryGetErrorFromStream(stream) {
|
|
387
394
|
return new Promise((resolve, reject) => {
|
|
388
|
-
|
|
395
|
+
let receivedData = false
|
|
396
|
+
|
|
397
|
+
miss.pipe(stream, miss.concat(parse), (err) => {
|
|
398
|
+
if (err) {
|
|
399
|
+
reject(err)
|
|
400
|
+
} else if (!receivedData) {
|
|
401
|
+
// Resolve with null if no data was received, to let the caller
|
|
402
|
+
// know we couldn't parse the error.
|
|
403
|
+
resolve(null)
|
|
404
|
+
}
|
|
405
|
+
})
|
|
389
406
|
|
|
390
407
|
function parse(body) {
|
|
408
|
+
receivedData = true
|
|
391
409
|
try {
|
|
392
410
|
const parsed = JSON.parse(body.toString('utf8'))
|
|
393
411
|
resolve(parsed.message || parsed.error || null)
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many retries to attempt when retrieving the document stream.
|
|
3
|
+
* User overridable as `options.maxRetries`.
|
|
4
|
+
*
|
|
5
|
+
* Note: Only for initial connection - if download fails while streaming, we cannot easily resume.
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
exports.DOCUMENT_STREAM_MAX_RETRIES = 5
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* How many retries to attempt when downloading an asset.
|
|
12
|
+
* User overridable as `options.maxAssetRetries`.
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
exports.ASSET_DOWNLOAD_MAX_RETRIES = 10
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* How many concurrent asset downloads to allow.
|
|
19
|
+
* User overridable as `options.assetConcurrency`.
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
exports.ASSET_DOWNLOAD_CONCURRENCY = 8
|
package/src/export.js
CHANGED
|
@@ -20,12 +20,16 @@ const validateOptions = require('./validateOptions')
|
|
|
20
20
|
|
|
21
21
|
const noop = () => null
|
|
22
22
|
|
|
23
|
-
function exportDataset(opts) {
|
|
23
|
+
async function exportDataset(opts) {
|
|
24
24
|
const options = validateOptions(opts)
|
|
25
25
|
const onProgress = options.onProgress || noop
|
|
26
26
|
const archive = archiver('tar', {
|
|
27
27
|
gzip: true,
|
|
28
|
-
gzipOptions: {
|
|
28
|
+
gzipOptions: {
|
|
29
|
+
level: options.compress
|
|
30
|
+
? zlib.constants.Z_DEFAULT_COMPRESSION
|
|
31
|
+
: zlib.constants.Z_NO_COMPRESSION,
|
|
32
|
+
},
|
|
29
33
|
})
|
|
30
34
|
|
|
31
35
|
const slugDate = new Date()
|
|
@@ -35,6 +39,9 @@ function exportDataset(opts) {
|
|
|
35
39
|
|
|
36
40
|
const prefix = `${opts.dataset}-export-${slugDate}`
|
|
37
41
|
const tmpDir = path.join(os.tmpdir(), prefix)
|
|
42
|
+
fs.mkdirSync(tmpDir, {recursive: true})
|
|
43
|
+
const dataPath = path.join(tmpDir, 'data.ndjson')
|
|
44
|
+
|
|
38
45
|
const cleanup = () =>
|
|
39
46
|
rimraf(tmpDir).catch((err) => {
|
|
40
47
|
debug(`Error while cleaning up temporary files: ${err.message}`)
|
|
@@ -45,10 +52,11 @@ function exportDataset(opts) {
|
|
|
45
52
|
tmpDir,
|
|
46
53
|
prefix,
|
|
47
54
|
concurrency: options.assetConcurrency,
|
|
55
|
+
maxRetries: options.maxAssetRetries,
|
|
48
56
|
})
|
|
49
57
|
|
|
50
|
-
debug('
|
|
51
|
-
debug('
|
|
58
|
+
debug('Downloading assets (temporarily) to %s', tmpDir)
|
|
59
|
+
debug('Downloading to %s', options.outputPath === '-' ? 'stdout' : options.outputPath)
|
|
52
60
|
|
|
53
61
|
let outputStream
|
|
54
62
|
if (isWritableStream(options.outputPath)) {
|
|
@@ -63,144 +71,160 @@ function exportDataset(opts) {
|
|
|
63
71
|
assetStreamHandler = options.assets ? assetHandler.rewriteAssets : assetHandler.stripAssets
|
|
64
72
|
}
|
|
65
73
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
reject(archiveErr)
|
|
73
|
-
return
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
debug('Archive finished!')
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
debug('Getting dataset export stream')
|
|
80
|
-
onProgress({step: 'Exporting documents...'})
|
|
74
|
+
let resolve
|
|
75
|
+
let reject
|
|
76
|
+
const result = new Promise((res, rej) => {
|
|
77
|
+
resolve = res
|
|
78
|
+
reject = rej
|
|
79
|
+
})
|
|
81
80
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
81
|
+
miss.finished(archive, async (archiveErr) => {
|
|
82
|
+
if (archiveErr) {
|
|
83
|
+
debug('Archiving errored: %s', archiveErr.stack)
|
|
84
|
+
await cleanup()
|
|
85
|
+
reject(archiveErr)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
86
88
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
onProgress({
|
|
90
|
-
step: 'Exporting documents...',
|
|
91
|
-
current: documentCount,
|
|
92
|
-
total: '?',
|
|
93
|
-
update: true,
|
|
94
|
-
})
|
|
89
|
+
debug('Archive finished')
|
|
90
|
+
})
|
|
95
91
|
|
|
96
|
-
|
|
97
|
-
|
|
92
|
+
debug('Getting dataset export stream')
|
|
93
|
+
onProgress({step: 'Exporting documents...'})
|
|
98
94
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
debug('Got HTTP %d', inputStream.statusCode)
|
|
104
|
-
debug('Response headers: %o', inputStream.headers)
|
|
105
|
-
|
|
106
|
-
const jsonStream = miss.pipeline(
|
|
107
|
-
inputStream,
|
|
108
|
-
logFirstChunk(),
|
|
109
|
-
split(tryParseJson),
|
|
110
|
-
rejectOnApiError(),
|
|
111
|
-
filterSystemDocuments(),
|
|
112
|
-
assetStreamHandler,
|
|
113
|
-
filterDocumentTypes(options.types),
|
|
114
|
-
options.drafts ? miss.through.obj() : filterDrafts(),
|
|
115
|
-
stringifyStream(),
|
|
116
|
-
miss.through(reportDocumentCount),
|
|
117
|
-
)
|
|
118
|
-
|
|
119
|
-
miss.finished(jsonStream, async (err) => {
|
|
120
|
-
if (err) {
|
|
121
|
-
return
|
|
122
|
-
}
|
|
95
|
+
let documentCount = 0
|
|
96
|
+
let lastReported = Date.now()
|
|
97
|
+
const reportDocumentCount = (chunk, enc, cb) => {
|
|
98
|
+
++documentCount
|
|
123
99
|
|
|
100
|
+
const now = Date.now()
|
|
101
|
+
if (now - lastReported > 50) {
|
|
124
102
|
onProgress({
|
|
125
103
|
step: 'Exporting documents...',
|
|
126
104
|
current: documentCount,
|
|
127
|
-
total:
|
|
105
|
+
total: '?',
|
|
128
106
|
update: true,
|
|
129
107
|
})
|
|
130
108
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
109
|
+
lastReported = now
|
|
110
|
+
}
|
|
134
111
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const completed =
|
|
138
|
-
assetHandler.queueSize - assetHandler.queue.size - assetHandler.queue.pending
|
|
139
|
-
|
|
140
|
-
if (prevCompleted === completed) {
|
|
141
|
-
return
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
prevCompleted = completed
|
|
145
|
-
onProgress({
|
|
146
|
-
step: 'Downloading assets...',
|
|
147
|
-
current: completed,
|
|
148
|
-
total: assetHandler.queueSize,
|
|
149
|
-
update: true,
|
|
150
|
-
})
|
|
151
|
-
}, 500)
|
|
152
|
-
|
|
153
|
-
debug('Waiting for asset handler to complete downloads')
|
|
154
|
-
try {
|
|
155
|
-
const assetMap = await assetHandler.finish()
|
|
156
|
-
|
|
157
|
-
// Make sure we mark the progress as done (eg 100/100 instead of 99/100)
|
|
158
|
-
onProgress({
|
|
159
|
-
step: 'Downloading assets...',
|
|
160
|
-
current: assetHandler.queueSize,
|
|
161
|
-
total: assetHandler.queueSize,
|
|
162
|
-
update: true,
|
|
163
|
-
})
|
|
164
|
-
|
|
165
|
-
archive.append(JSON.stringify(assetMap), {name: 'assets.json', prefix})
|
|
166
|
-
clearInterval(progressInterval)
|
|
167
|
-
} catch (assetErr) {
|
|
168
|
-
clearInterval(progressInterval)
|
|
169
|
-
await cleanup()
|
|
170
|
-
reject(assetErr)
|
|
171
|
-
return
|
|
172
|
-
}
|
|
112
|
+
cb(null, chunk)
|
|
113
|
+
}
|
|
173
114
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
115
|
+
const inputStream = await getDocumentsStream(options)
|
|
116
|
+
debug('Got HTTP %d', inputStream.statusCode)
|
|
117
|
+
debug('Response headers: %o', inputStream.headers)
|
|
118
|
+
|
|
119
|
+
const jsonStream = miss.pipeline(
|
|
120
|
+
inputStream,
|
|
121
|
+
logFirstChunk(),
|
|
122
|
+
split(tryParseJson),
|
|
123
|
+
rejectOnApiError(),
|
|
124
|
+
filterSystemDocuments(),
|
|
125
|
+
assetStreamHandler,
|
|
126
|
+
filterDocumentTypes(options.types),
|
|
127
|
+
options.drafts ? miss.through.obj() : filterDrafts(),
|
|
128
|
+
stringifyStream(),
|
|
129
|
+
miss.through(reportDocumentCount),
|
|
130
|
+
)
|
|
177
131
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
132
|
+
miss.pipe(jsonStream, fs.createWriteStream(dataPath), async (err) => {
|
|
133
|
+
if (err) {
|
|
134
|
+
debug('Export stream error: ', err)
|
|
135
|
+
reject(err)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
182
138
|
|
|
183
|
-
|
|
184
|
-
|
|
139
|
+
debug('Export stream completed')
|
|
140
|
+
onProgress({
|
|
141
|
+
step: 'Exporting documents...',
|
|
142
|
+
current: documentCount,
|
|
143
|
+
total: documentCount,
|
|
144
|
+
update: true,
|
|
185
145
|
})
|
|
186
146
|
|
|
187
|
-
|
|
188
|
-
|
|
147
|
+
debug('Adding data.ndjson to archive')
|
|
148
|
+
archive.file(dataPath, {name: 'data.ndjson', prefix})
|
|
189
149
|
|
|
190
|
-
|
|
191
|
-
onProgress({step: '
|
|
192
|
-
|
|
150
|
+
if (!options.raw && options.assets) {
|
|
151
|
+
onProgress({step: 'Downloading assets...'})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let prevCompleted = 0
|
|
155
|
+
const progressInterval = setInterval(() => {
|
|
156
|
+
const completed =
|
|
157
|
+
assetHandler.queueSize - assetHandler.queue.size - assetHandler.queue.pending
|
|
193
158
|
|
|
194
|
-
if (
|
|
195
|
-
resolve()
|
|
159
|
+
if (prevCompleted === completed) {
|
|
196
160
|
return
|
|
197
161
|
}
|
|
198
162
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
163
|
+
prevCompleted = completed
|
|
164
|
+
onProgress({
|
|
165
|
+
step: 'Downloading assets...',
|
|
166
|
+
current: completed,
|
|
167
|
+
total: assetHandler.queueSize,
|
|
168
|
+
update: true,
|
|
169
|
+
})
|
|
170
|
+
}, 500)
|
|
171
|
+
|
|
172
|
+
debug('Waiting for asset handler to complete downloads')
|
|
173
|
+
try {
|
|
174
|
+
const assetMap = await assetHandler.finish()
|
|
175
|
+
|
|
176
|
+
// Make sure we mark the progress as done (eg 100/100 instead of 99/100)
|
|
177
|
+
onProgress({
|
|
178
|
+
step: 'Downloading assets...',
|
|
179
|
+
current: assetHandler.queueSize,
|
|
180
|
+
total: assetHandler.queueSize,
|
|
181
|
+
update: true,
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
archive.append(JSON.stringify(assetMap), {name: 'assets.json', prefix})
|
|
185
|
+
clearInterval(progressInterval)
|
|
186
|
+
} catch (assetErr) {
|
|
187
|
+
clearInterval(progressInterval)
|
|
188
|
+
await cleanup()
|
|
189
|
+
reject(assetErr)
|
|
190
|
+
return
|
|
202
191
|
}
|
|
192
|
+
|
|
193
|
+
// Add all downloaded assets to archive
|
|
194
|
+
archive.directory(path.join(tmpDir, 'files'), `${prefix}/files`, {store: true})
|
|
195
|
+
archive.directory(path.join(tmpDir, 'images'), `${prefix}/images`, {store: true})
|
|
196
|
+
|
|
197
|
+
debug('Finalizing archive, flushing streams')
|
|
198
|
+
onProgress({step: 'Adding assets to archive...'})
|
|
199
|
+
await archive.finalize()
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
archive.on('warning', (err) => {
|
|
203
|
+
debug('Archive warning: %s', err.message)
|
|
203
204
|
})
|
|
205
|
+
|
|
206
|
+
miss.pipe(archive, outputStream, onComplete)
|
|
207
|
+
|
|
208
|
+
async function onComplete(err) {
|
|
209
|
+
onProgress({step: 'Clearing temporary files...'})
|
|
210
|
+
await cleanup()
|
|
211
|
+
|
|
212
|
+
if (!err) {
|
|
213
|
+
debug('Export completed')
|
|
214
|
+
resolve({
|
|
215
|
+
outputPath: options.outputPath,
|
|
216
|
+
documentCount,
|
|
217
|
+
assetCount: assetHandler.filesWritten,
|
|
218
|
+
})
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
debug('Error during streaming: %s', err.stack)
|
|
223
|
+
assetHandler.clear()
|
|
224
|
+
reject(err)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return result
|
|
204
228
|
}
|
|
205
229
|
|
|
206
230
|
function isWritableStream(val) {
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
const pkg = require('../package.json')
|
|
2
2
|
const requestStream = require('./requestStream')
|
|
3
3
|
|
|
4
|
-
module.exports = (
|
|
4
|
+
module.exports = (options) => {
|
|
5
5
|
// Sanity client doesn't handle streams natively since we want to support node/browser
|
|
6
6
|
// with same API. We're just using it here to get hold of URLs and tokens.
|
|
7
|
-
const url = client.getUrl(`/data/export/${dataset}`)
|
|
8
|
-
const token = client.config().token
|
|
7
|
+
const url = options.client.getUrl(`/data/export/${options.dataset}`)
|
|
8
|
+
const token = options.client.config().token
|
|
9
9
|
const headers = {
|
|
10
10
|
'User-Agent': `${pkg.name}@${pkg.version}`,
|
|
11
11
|
...(token ? {Authorization: `Bearer ${token}`} : {}),
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
return requestStream({url, headers})
|
|
14
|
+
return requestStream({url, headers, maxRetries: options.maxRetries})
|
|
15
15
|
}
|
package/src/rejectOnApiError.js
CHANGED
|
@@ -3,7 +3,13 @@ const miss = require('mississippi')
|
|
|
3
3
|
module.exports = () =>
|
|
4
4
|
miss.through.obj((doc, enc, callback) => {
|
|
5
5
|
if (doc.error && doc.statusCode) {
|
|
6
|
-
callback(
|
|
6
|
+
callback(
|
|
7
|
+
new Error(
|
|
8
|
+
['Export', `HTTP ${doc.statusCode}`, doc.error, doc.message]
|
|
9
|
+
.filter((part) => typeof part === 'string')
|
|
10
|
+
.join(': '),
|
|
11
|
+
),
|
|
12
|
+
)
|
|
7
13
|
return
|
|
8
14
|
}
|
|
9
15
|
|
package/src/requestStream.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
const {getIt} = require('get-it')
|
|
2
2
|
const {keepAlive, promise} = require('get-it/middleware')
|
|
3
3
|
const debug = require('./debug')
|
|
4
|
+
const {extractFirstError} = require('./util/extractFirstError')
|
|
5
|
+
const {DOCUMENT_STREAM_MAX_RETRIES} = require('./constants')
|
|
4
6
|
|
|
5
7
|
const request = getIt([keepAlive(), promise({onlyBody: true})])
|
|
6
8
|
const socketsWithTimeout = new WeakSet()
|
|
7
9
|
|
|
8
10
|
const CONNECTION_TIMEOUT = 15 * 1000 // 15 seconds
|
|
9
11
|
const READ_TIMEOUT = 3 * 60 * 1000 // 3 minutes
|
|
10
|
-
const
|
|
12
|
+
const RETRY_DELAY_MS = 1500 // 1.5 seconds
|
|
11
13
|
|
|
12
14
|
function delay(ms) {
|
|
13
15
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
@@ -15,8 +17,11 @@ function delay(ms) {
|
|
|
15
17
|
|
|
16
18
|
/* eslint-disable no-await-in-loop, max-depth */
|
|
17
19
|
module.exports = async (options) => {
|
|
20
|
+
const maxRetries =
|
|
21
|
+
typeof options.maxRetries === 'number' ? options.maxRetries : DOCUMENT_STREAM_MAX_RETRIES
|
|
22
|
+
|
|
18
23
|
let error
|
|
19
|
-
for (let i = 0; i <
|
|
24
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
20
25
|
try {
|
|
21
26
|
const response = await request({
|
|
22
27
|
...options,
|
|
@@ -33,23 +38,24 @@ module.exports = async (options) => {
|
|
|
33
38
|
socketsWithTimeout.add(response.connection)
|
|
34
39
|
response.connection.setTimeout(READ_TIMEOUT, () => {
|
|
35
40
|
response.destroy(
|
|
36
|
-
new Error(`Read timeout: No data received on socket for ${READ_TIMEOUT} ms`),
|
|
41
|
+
new Error(`Export: Read timeout: No data received on socket for ${READ_TIMEOUT} ms`),
|
|
37
42
|
)
|
|
38
43
|
})
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
return response
|
|
42
47
|
} catch (err) {
|
|
43
|
-
error = err
|
|
48
|
+
error = extractFirstError(err)
|
|
44
49
|
|
|
45
50
|
if (err.response && err.response.statusCode && err.response.statusCode < 500) {
|
|
46
51
|
break
|
|
47
52
|
}
|
|
48
53
|
|
|
49
|
-
debug('Error, retrying after
|
|
50
|
-
await delay(
|
|
54
|
+
debug('Error, retrying after %d ms: %s', RETRY_DELAY_MS, error.message)
|
|
55
|
+
await delay(RETRY_DELAY_MS)
|
|
51
56
|
}
|
|
52
57
|
}
|
|
53
58
|
|
|
59
|
+
error.message = `Export: Failed to fetch ${options.url}: ${error.message}`
|
|
54
60
|
throw error
|
|
55
61
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
exports.extractFirstError = function extractFirstError(err) {
|
|
2
|
+
if (
|
|
3
|
+
// eslint-disable-next-line no-undef
|
|
4
|
+
((typeof AggregateError !== 'undefined' && err instanceof AggregateError) ||
|
|
5
|
+
('name' in err && err.name === 'AggregateError')) &&
|
|
6
|
+
Array.isArray(err.errors) &&
|
|
7
|
+
err.errors.length > 0 &&
|
|
8
|
+
'message' in err.errors[0]
|
|
9
|
+
) {
|
|
10
|
+
return err.errors[0]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return err
|
|
14
|
+
}
|
package/src/validateOptions.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
const defaults = require('lodash/defaults')
|
|
2
|
+
const {DOCUMENT_STREAM_MAX_RETRIES, ASSET_DOWNLOAD_MAX_RETRIES} = require('./constants')
|
|
2
3
|
|
|
3
4
|
const clientMethods = ['getUrl', 'config']
|
|
4
5
|
const booleanFlags = ['assets', 'raw', 'compress', 'drafts']
|
|
6
|
+
const numberFlags = ['maxAssetRetries', 'maxRetries', 'assetConcurrency']
|
|
5
7
|
const exportDefaults = {
|
|
6
8
|
compress: true,
|
|
7
9
|
drafts: true,
|
|
8
10
|
assets: true,
|
|
9
11
|
raw: false,
|
|
12
|
+
maxRetries: DOCUMENT_STREAM_MAX_RETRIES,
|
|
13
|
+
maxAssetRetries: ASSET_DOWNLOAD_MAX_RETRIES,
|
|
10
14
|
}
|
|
11
15
|
|
|
12
16
|
function validateOptions(opts) {
|
|
@@ -42,6 +46,12 @@ function validateOptions(opts) {
|
|
|
42
46
|
}
|
|
43
47
|
})
|
|
44
48
|
|
|
49
|
+
numberFlags.forEach((flag) => {
|
|
50
|
+
if (typeof options[flag] !== 'undefined' && typeof options[flag] !== 'number') {
|
|
51
|
+
throw new Error(`Flag ${flag} must be a number if specified`)
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
45
55
|
if (!options.outputPath) {
|
|
46
56
|
throw new Error('outputPath must be specified (- for stdout)')
|
|
47
57
|
}
|