data-ark 0.1.0 → 0.1.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/README.md +28 -28
- package/bin/data-ark.js +17 -17
- package/package.json +20 -2
- package/src/chunking.js +5 -5
- package/src/cli.js +14 -14
- package/src/client.js +5 -5
- package/src/commands/login.js +16 -16
- package/src/commands/logout.js +4 -4
- package/src/commands/restore.js +29 -28
- package/src/commands/upload.js +27 -27
- package/src/config.js +1 -1
- package/src/downloader.js +3 -3
- package/src/manifest.js +14 -13
- package/src/progress.js +2 -2
- package/src/uploader.js +16 -15
package/README.md
CHANGED
|
@@ -1,50 +1,50 @@
|
|
|
1
1
|
# data-ark
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Split large files into 1.8GB chunks, store them on Telegram, and restore them intact.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Quick start
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npx data-ark login #
|
|
9
|
-
npx data-ark data.tar --to @
|
|
10
|
-
npx data-ark data.tar #
|
|
8
|
+
npx data-ark login # once only
|
|
9
|
+
npx data-ark data.tar --to @my_backups # the destination is remembered
|
|
10
|
+
npx data-ark data.tar # from the second run on
|
|
11
11
|
npx data-ark restore ark-20260905-7f3a91
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
##
|
|
14
|
+
## What you need
|
|
15
15
|
|
|
16
|
-
- Node.js 18
|
|
17
|
-
- `api_id`
|
|
16
|
+
- Node.js 18 or newer.
|
|
17
|
+
- An `api_id` and `api_hash` from <https://my.telegram.org> → API development tools. The `login` command asks for both, plus your phone number and the verification code.
|
|
18
18
|
|
|
19
|
-
data-ark
|
|
19
|
+
data-ark signs in with your own Telegram account (MTProto), not a bot. That is a hard requirement: the Bot API caps uploads at 50MB per file, while a user account gets 2GB.
|
|
20
20
|
|
|
21
|
-
##
|
|
21
|
+
## Options
|
|
22
22
|
|
|
23
|
-
|
|
|
23
|
+
| Flag | Default | Meaning |
|
|
24
24
|
|---|---|---|
|
|
25
|
-
| `--to <chat>` |
|
|
26
|
-
| `--chunk-size <n>` | `1800MB` |
|
|
27
|
-
| `--concurrency <n>` | `8` |
|
|
28
|
-
| `--out
|
|
25
|
+
| `--to <chat>` | the remembered destination | `@username`, `-100123…`, or `me` |
|
|
26
|
+
| `--chunk-size <n>` | `1800MB` | e.g. `1.8GB`, `500MB`. Hard ceiling 1950MB. |
|
|
27
|
+
| `--concurrency <n>` | `8` | 512KB parts sent in parallel. An integer from 1 to 64. |
|
|
28
|
+
| `--out <path>` | the basename from the manifest | Where to write the restored file; relative paths resolve against the current directory |
|
|
29
29
|
|
|
30
|
-
##
|
|
30
|
+
## How it works
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
Every run mints a `backupId`. The file is read directly by offset — no temporary copies — and uploaded as documents named `<backupId>.partNNNN`. Once every chunk is up, data-ark sends a JSON manifest listing the message id and sha256 of each one. Restore needs only the `backupId`: it finds the manifest in the chat, downloads each chunk to its exact position in a `.partial` file, checks every chunk's sha256 and size, and renames it to the real file only after *all* chunks match.
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
If the connection drops during an **upload**, just run the same command again — progress lives in `~/.data-ark/state/` and finished chunks are skipped, keeping the same `backupId`. Two things to know about rerunning:
|
|
35
35
|
|
|
36
|
-
-
|
|
37
|
-
-
|
|
36
|
+
- Running again with a `--to` that differs from the destination stored in the unfinished progress makes data-ark **refuse to run** rather than silently redirect — one backup cannot be split across two destinations. The error points the way out: drop `--to` to keep sending to the original destination, or delete the state file to start a new backup.
|
|
37
|
+
- Running again with a different `--chunk-size` is treated as an entirely new backup (new backup id), not a resume.
|
|
38
38
|
|
|
39
|
-
**Restore
|
|
39
|
+
**Restore keeps no state to resume from.** Pressing `Ctrl-C` mid-restore saves nothing — running again starts over.
|
|
40
40
|
|
|
41
|
-
##
|
|
41
|
+
## Limits worth knowing
|
|
42
42
|
|
|
43
|
-
-
|
|
44
|
-
-
|
|
45
|
-
-
|
|
46
|
-
-
|
|
43
|
+
- A chunk cannot exceed 1950MB: Telegram accepts at most 4000 parts of 512KB per file, an arithmetic ceiling of about 1953MB, and data-ark stops at 1950MB to leave a safety margin.
|
|
44
|
+
- The data is **not** encrypted. Don't upload anything you would mind sitting on someone else's infrastructure.
|
|
45
|
+
- Deleting a chunk message on Telegram destroys the backup, with no way to recover it.
|
|
46
|
+
- Keep the `backupId`. Without it you have to hunt for the manifest in the chat by hand.
|
|
47
47
|
|
|
48
|
-
##
|
|
48
|
+
## Where the config lives
|
|
49
49
|
|
|
50
|
-
`~/.data-ark/config.json` (
|
|
50
|
+
`~/.data-ark/config.json` (mode 600) holds `apiId`, `apiHash`, the session and the default destination. `npx data-ark logout` **only deletes the locally stored session** and keeps the rest — the session is still alive on Telegram's side. To revoke access for good, open Telegram → Settings → Devices (Active sessions) and terminate that session.
|
package/bin/data-ark.js
CHANGED
|
@@ -7,20 +7,20 @@ import { runUpload } from '../src/commands/upload.js'
|
|
|
7
7
|
|
|
8
8
|
const SIGINT_EXIT_CODE = 130
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
//
|
|
10
|
+
// Which command is running when Ctrl-C arrives — each one tells a different truth
|
|
11
|
+
// about whether progress was saved, so we need to know which to pick the right line.
|
|
12
12
|
let currentCommand = null
|
|
13
13
|
|
|
14
14
|
function sigintMessage(command) {
|
|
15
15
|
if (command === 'upload') {
|
|
16
|
-
return '\
|
|
16
|
+
return '\nStopped. Progress is saved, run the same command again to continue.\n'
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
if (command === 'restore') {
|
|
20
|
-
return '\
|
|
20
|
+
return '\nStopped. Download progress is not saved, running again starts over.\n'
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
return '\
|
|
23
|
+
return '\nStopped.\n'
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
process.on('SIGINT', () => {
|
|
@@ -34,7 +34,7 @@ async function main() {
|
|
|
34
34
|
try {
|
|
35
35
|
parsed = route(process.argv.slice(2))
|
|
36
36
|
} catch (err) {
|
|
37
|
-
process.stderr.write(`
|
|
37
|
+
process.stderr.write(`Error: ${err.message}\n\n${HELP}`)
|
|
38
38
|
process.exitCode = 2
|
|
39
39
|
return
|
|
40
40
|
}
|
|
@@ -60,24 +60,24 @@ async function main() {
|
|
|
60
60
|
|
|
61
61
|
case 'restore':
|
|
62
62
|
if (!parsed.args[0]) {
|
|
63
|
-
throw new Error('
|
|
63
|
+
throw new Error('Missing backup id. Example: npx data-ark restore ark-20260905-7f3a91')
|
|
64
64
|
}
|
|
65
65
|
await runRestore(parsed.args[0], parsed.options)
|
|
66
66
|
return
|
|
67
67
|
|
|
68
68
|
default:
|
|
69
|
-
throw new Error(`
|
|
69
|
+
throw new Error(`Unknown command: ${parsed.command}`)
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
// GramJS
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
73
|
+
// GramJS keeps "exported senders" around together with a 30-second timer to release
|
|
74
|
+
// them, and neither client.disconnect() nor destroy() cleans them up: both of those
|
|
75
|
+
// maps are Maps, but the code walks them with Object.values and so misses everything.
|
|
76
|
+
// The result is a command that prints "Done" and then hangs for another ~30 seconds,
|
|
77
|
+
// during which Ctrl-C falsely reports that nothing was saved. Finish the work, exit.
|
|
78
78
|
function exitWhenFlushed(code) {
|
|
79
|
-
//
|
|
80
|
-
//
|
|
79
|
+
// The empty writes exist only to borrow their callbacks: they fire after everything
|
|
80
|
+
// queued earlier has flushed, so nothing is lost when stdout/stderr is not a TTY.
|
|
81
81
|
let pending = 2
|
|
82
82
|
|
|
83
83
|
const done = () => {
|
|
@@ -85,7 +85,7 @@ function exitWhenFlushed(code) {
|
|
|
85
85
|
if (pending === 0) process.exit(code)
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
//
|
|
88
|
+
// Safety net: exit anyway if a callback never arrives (the pipe is already closed).
|
|
89
89
|
setTimeout(() => process.exit(code), 2000).unref()
|
|
90
90
|
|
|
91
91
|
process.stdout.write('', done)
|
|
@@ -97,7 +97,7 @@ main().then(
|
|
|
97
97
|
exitWhenFlushed(process.exitCode ?? 0)
|
|
98
98
|
},
|
|
99
99
|
(err) => {
|
|
100
|
-
process.stderr.write(`
|
|
100
|
+
process.stderr.write(`Error: ${err.message}\n`)
|
|
101
101
|
process.exitCode = 1
|
|
102
102
|
exitWhenFlushed(1)
|
|
103
103
|
},
|
package/package.json
CHANGED
|
@@ -1,9 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "data-ark",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Split large files into chunks and store them on Telegram",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"telegram",
|
|
7
|
+
"backup",
|
|
8
|
+
"chunk",
|
|
9
|
+
"split",
|
|
10
|
+
"restore",
|
|
11
|
+
"cli",
|
|
12
|
+
"mtproto"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/shovity/data-ark#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/shovity/data-ark/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/shovity/data-ark.git"
|
|
21
|
+
},
|
|
5
22
|
"type": "module",
|
|
6
23
|
"license": "MIT",
|
|
24
|
+
"author": "shovity",
|
|
7
25
|
"bin": {
|
|
8
26
|
"data-ark": "bin/data-ark.js"
|
|
9
27
|
},
|
package/src/chunking.js
CHANGED
|
@@ -17,19 +17,19 @@ export function parseSize(input) {
|
|
|
17
17
|
const match = text.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/)
|
|
18
18
|
|
|
19
19
|
if (!match) {
|
|
20
|
-
throw new Error(`
|
|
20
|
+
throw new Error(`Invalid size: "${input}". Valid examples: 1800MB, 1.8GB, 524288.`)
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
const bytes = Math.floor(Number(match[1]) * UNITS[match[2] ?? 'b'])
|
|
24
24
|
|
|
25
25
|
if (bytes <= 0) {
|
|
26
|
-
throw new Error('
|
|
26
|
+
throw new Error('Size must be greater than 0.')
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
if (bytes > MAX_CHUNK_SIZE) {
|
|
30
30
|
throw new Error(
|
|
31
|
-
'
|
|
32
|
-
'
|
|
31
|
+
'Maximum chunk size is 1950MB. Telegram accepts only 4000 parts of 512KB per file, ' +
|
|
32
|
+
'an arithmetic ceiling of about 1953MB, so a safety margin is needed.',
|
|
33
33
|
)
|
|
34
34
|
}
|
|
35
35
|
|
|
@@ -38,7 +38,7 @@ export function parseSize(input) {
|
|
|
38
38
|
|
|
39
39
|
export function planChunks(fileSize, chunkSize) {
|
|
40
40
|
if (fileSize <= 0) {
|
|
41
|
-
throw new Error('File
|
|
41
|
+
throw new Error('File is empty, nothing to upload.')
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
const chunks = []
|
package/src/cli.js
CHANGED
|
@@ -10,20 +10,20 @@ const OPTIONS = {
|
|
|
10
10
|
help: { type: 'boolean', short: 'h' },
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export const HELP = `data-ark —
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
npx data-ark login
|
|
17
|
-
npx data-ark <file>
|
|
18
|
-
npx data-ark restore <backup-id>
|
|
19
|
-
npx data-ark logout
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
--to <chat>
|
|
23
|
-
--chunk-size <n>
|
|
24
|
-
--concurrency <n>
|
|
25
|
-
--out
|
|
26
|
-
-h, --help
|
|
13
|
+
export const HELP = `data-ark — split large files into chunks and store them on Telegram
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
npx data-ark login Log in to Telegram, only needed once
|
|
17
|
+
npx data-ark <file> Split a file and upload it to Telegram
|
|
18
|
+
npx data-ark restore <backup-id> Download the chunks and reassemble the file
|
|
19
|
+
npx data-ark logout Remove the saved session
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
--to <chat> Destination: @username, -100123..., or me. Remembered for next time.
|
|
23
|
+
--chunk-size <n> Size of each chunk, default 1800MB. Examples: 1.8GB, 500MB.
|
|
24
|
+
--concurrency <n> 512KB parts sent in parallel, default 8, max 64.
|
|
25
|
+
--out <path> Where to write the restored file. Defaults to the basename in the manifest.
|
|
26
|
+
-h, --help Show this help.
|
|
27
27
|
`
|
|
28
28
|
|
|
29
29
|
export function route(argv) {
|
package/src/client.js
CHANGED
|
@@ -5,7 +5,7 @@ export function normalizeChatTarget(input) {
|
|
|
5
5
|
const text = String(input).trim()
|
|
6
6
|
|
|
7
7
|
if (text === '') {
|
|
8
|
-
throw new Error('
|
|
8
|
+
throw new Error('Destination must not be empty.')
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
if (/^-?\d+$/.test(text)) {
|
|
@@ -20,8 +20,8 @@ export function requireChat(options, config) {
|
|
|
20
20
|
|
|
21
21
|
if (!raw) {
|
|
22
22
|
throw new Error(
|
|
23
|
-
'
|
|
24
|
-
'
|
|
23
|
+
'No destination set — run again with --to @my_backups (or --to me for Saved Messages). ' +
|
|
24
|
+
'data-ark will remember it next time.',
|
|
25
25
|
)
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -30,7 +30,7 @@ export function requireChat(options, config) {
|
|
|
30
30
|
|
|
31
31
|
export function assertLoggedIn(config) {
|
|
32
32
|
if (!config.session || !config.apiId || !config.apiHash) {
|
|
33
|
-
throw new Error('
|
|
33
|
+
throw new Error('Not logged in — run "npx data-ark login" first.')
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -45,7 +45,7 @@ export async function connect(config) {
|
|
|
45
45
|
await client.connect()
|
|
46
46
|
|
|
47
47
|
if (!(await client.isUserAuthorized())) {
|
|
48
|
-
throw new Error('
|
|
48
|
+
throw new Error('Session expired — run "npx data-ark login".')
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
return client
|
package/src/commands/login.js
CHANGED
|
@@ -16,11 +16,11 @@ function createPrompts() {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
const LOGIN_ERROR_MESSAGES = {
|
|
19
|
-
PHONE_NUMBER_INVALID: '
|
|
20
|
-
PHONE_CODE_INVALID: '
|
|
21
|
-
PHONE_CODE_EXPIRED: '
|
|
22
|
-
PASSWORD_HASH_INVALID: '
|
|
23
|
-
FLOOD_WAIT: '
|
|
19
|
+
PHONE_NUMBER_INVALID: 'invalid phone number',
|
|
20
|
+
PHONE_CODE_INVALID: 'wrong verification code',
|
|
21
|
+
PHONE_CODE_EXPIRED: 'verification code expired',
|
|
22
|
+
PASSWORD_HASH_INVALID: 'wrong two-step password',
|
|
23
|
+
FLOOD_WAIT: 'rate limited by Telegram, need to wait',
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export function describeLoginError(err) {
|
|
@@ -36,30 +36,30 @@ export function describeLoginError(err) {
|
|
|
36
36
|
export async function runLogin({ configDir = defaultConfigDir(), prompts = createPrompts() } = {}) {
|
|
37
37
|
const config = await loadConfig(configDir)
|
|
38
38
|
|
|
39
|
-
console.log('
|
|
39
|
+
console.log('You need your own api_id and api_hash. Get them at https://my.telegram.org → API development tools.\n')
|
|
40
40
|
|
|
41
41
|
const apiIdAnswer = (await prompts.ask(`api_id${config.apiId ? ` [${config.apiId}]` : ''}: `)).trim()
|
|
42
42
|
|
|
43
43
|
if (apiIdAnswer !== '' && !/^\d+$/.test(apiIdAnswer)) {
|
|
44
44
|
prompts.close()
|
|
45
|
-
throw new Error('api_id
|
|
45
|
+
throw new Error('api_id must be an integer.')
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
const apiId = apiIdAnswer === '' ? config.apiId : Number(apiIdAnswer)
|
|
49
|
-
const apiHash = (await prompts.ask(`api_hash${config.apiHash ? ' [
|
|
49
|
+
const apiHash = (await prompts.ask(`api_hash${config.apiHash ? ' [keep current]' : ''}: `)) || config.apiHash
|
|
50
50
|
|
|
51
51
|
if (!apiId || !apiHash) {
|
|
52
52
|
prompts.close()
|
|
53
|
-
throw new Error('
|
|
53
|
+
throw new Error('Missing api_id or api_hash.')
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
const client = new TelegramClient(new StringSession(''), apiId, apiHash, { connectionRetries: 5 })
|
|
57
57
|
|
|
58
58
|
await client.start({
|
|
59
|
-
phoneNumber: () => prompts.ask('
|
|
60
|
-
phoneCode: () => prompts.ask('
|
|
61
|
-
password: () => prompts.ask('
|
|
62
|
-
onError: (err) => console.error(`
|
|
59
|
+
phoneNumber: () => prompts.ask('Phone number (e.g. +1...): '),
|
|
60
|
+
phoneCode: () => prompts.ask('Verification code Telegram just sent: '),
|
|
61
|
+
password: () => prompts.ask('Two-step password (leave blank if not enabled): '),
|
|
62
|
+
onError: (err) => console.error(`Login failed: ${describeLoginError(err)}`),
|
|
63
63
|
})
|
|
64
64
|
|
|
65
65
|
const me = await client.getMe()
|
|
@@ -68,7 +68,7 @@ export async function runLogin({ configDir = defaultConfigDir(), prompts = creat
|
|
|
68
68
|
|
|
69
69
|
const chatAnswer = (
|
|
70
70
|
await prompts.ask(
|
|
71
|
-
|
|
71
|
+
`Which chat should backups go to? (@username, -100..., or me)${config.defaultChat ? ` [${config.defaultChat}]` : ''}, Enter to skip: `,
|
|
72
72
|
)
|
|
73
73
|
).trim()
|
|
74
74
|
|
|
@@ -82,6 +82,6 @@ export async function runLogin({ configDir = defaultConfigDir(), prompts = creat
|
|
|
82
82
|
|
|
83
83
|
await saveConfig(next, configDir)
|
|
84
84
|
|
|
85
|
-
console.log(`\
|
|
86
|
-
console.log(`
|
|
85
|
+
console.log(`\nLogged in as ${me.username ? `@${me.username}` : me.firstName}.`)
|
|
86
|
+
console.log(`Config saved to ${configDir}/config.json`)
|
|
87
87
|
}
|
package/src/commands/logout.js
CHANGED
|
@@ -3,11 +3,11 @@ import { clearSession, defaultConfigDir } from '../config.js'
|
|
|
3
3
|
export async function runLogout({ configDir = defaultConfigDir() } = {}) {
|
|
4
4
|
await clearSession(configDir)
|
|
5
5
|
console.log(
|
|
6
|
-
'
|
|
6
|
+
'Removed the session stored on this machine. api_id, api_hash and the destination are kept.',
|
|
7
7
|
)
|
|
8
8
|
console.log(
|
|
9
|
-
'
|
|
10
|
-
'
|
|
11
|
-
'
|
|
9
|
+
'Note: this only deletes the local copy — the session is still alive on Telegram\'s side. ' +
|
|
10
|
+
'To revoke access for good, open Telegram → Settings → Devices (Active sessions) ' +
|
|
11
|
+
'and terminate that session.',
|
|
12
12
|
)
|
|
13
13
|
}
|
package/src/commands/restore.js
CHANGED
|
@@ -20,8 +20,8 @@ function documentFileName(message) {
|
|
|
20
20
|
async function realSearchManifest(client, peer, backupId) {
|
|
21
21
|
const wanted = manifestFileName(backupId)
|
|
22
22
|
|
|
23
|
-
//
|
|
24
|
-
//
|
|
23
|
+
// Use client.getMessages instead of a raw Api.messages.Search: it handles offsets,
|
|
24
|
+
// hashes and pagination itself, so we don't hand-build easily mistyped fields.
|
|
25
25
|
const messages = await client.getMessages(peer, {
|
|
26
26
|
search: backupId,
|
|
27
27
|
filter: new Api.InputMessagesFilterDocument(),
|
|
@@ -44,17 +44,17 @@ export async function realDownloadChunk(client, message, handle, offset, onProgr
|
|
|
44
44
|
return await downloadToFile(client, message, handle.fd, { offset, onProgress })
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
// manifest.name
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
47
|
+
// manifest.name comes from data downloaded off Telegram — don't trust it when picking
|
|
48
|
+
// a path ourselves. path.basename stops "../../x" but still returns "..", "." or "" for
|
|
49
|
+
// a few pathological names: path.resolve('..') is the parent directory, so a multi-GB
|
|
50
|
+
// .partial file would land outside the current directory and only blow up at rename.
|
|
51
51
|
function safeOutName(name) {
|
|
52
52
|
const base = path.basename(String(name ?? ''))
|
|
53
53
|
|
|
54
54
|
if (base === '' || base === '.' || base === '..') {
|
|
55
55
|
throw new Error(
|
|
56
|
-
`
|
|
57
|
-
'
|
|
56
|
+
`The name in the manifest ("${name}") cannot be used as a file name. ` +
|
|
57
|
+
'Run again with --out <path> to choose where to write.',
|
|
58
58
|
)
|
|
59
59
|
}
|
|
60
60
|
|
|
@@ -65,7 +65,7 @@ async function askConfirm(question) {
|
|
|
65
65
|
const rl = readline.createInterface({ input: stdin, output: stdout })
|
|
66
66
|
const answer = await rl.question(question)
|
|
67
67
|
rl.close()
|
|
68
|
-
return /^
|
|
68
|
+
return /^y/i.test(answer.trim())
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
export async function runRestore(backupId, options = {}, deps = {}) {
|
|
@@ -94,18 +94,18 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
94
94
|
|
|
95
95
|
if (!manifestMessage) {
|
|
96
96
|
throw new Error(
|
|
97
|
-
`
|
|
98
|
-
'
|
|
97
|
+
`No manifest found for ${backupId} in ${chat}. ` +
|
|
98
|
+
'Check the backup id, or use --to to point at the right chat.',
|
|
99
99
|
)
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
const manifest = parseManifest(await readMessageBytes(client, manifestMessage))
|
|
103
|
-
//
|
|
103
|
+
// When the user passes --out, respect that path verbatim.
|
|
104
104
|
const target = path.resolve(options.out ?? safeOutName(manifest.name))
|
|
105
105
|
const partial = `${target}.partial`
|
|
106
106
|
|
|
107
|
-
//
|
|
108
|
-
//
|
|
107
|
+
// Only ENOENT means "no file yet". Treating a permission or I/O error as absence
|
|
108
|
+
// would have data-ark overwrite the user's file without asking.
|
|
109
109
|
let exists = true
|
|
110
110
|
try {
|
|
111
111
|
await fs.stat(target)
|
|
@@ -114,12 +114,12 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
114
114
|
exists = false
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
if (exists && !(await confirm(`${target}
|
|
118
|
-
throw new Error('
|
|
117
|
+
if (exists && !(await confirm(`${target} already exists. Overwrite? [y/N] `))) {
|
|
118
|
+
throw new Error('Cancelled on request.')
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
log(`Backup ${manifest.id}`)
|
|
122
|
-
log(`File ${target} (${formatBytes(manifest.size)}, ${manifest.chunks.length}
|
|
122
|
+
log(`File ${target} (${formatBytes(manifest.size)}, ${manifest.chunks.length} chunks)\n`)
|
|
123
123
|
|
|
124
124
|
const handle = await fs.open(partial, 'w+')
|
|
125
125
|
|
|
@@ -131,8 +131,8 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
131
131
|
|
|
132
132
|
if (!message) {
|
|
133
133
|
throw new Error(
|
|
134
|
-
`
|
|
135
|
-
'
|
|
134
|
+
`Missing chunk ${chunk.i + 1}/${manifest.chunks.length}: message ${chunk.msgId} is no longer in ${chat}. ` +
|
|
135
|
+
'This backup cannot be restored.',
|
|
136
136
|
)
|
|
137
137
|
}
|
|
138
138
|
|
|
@@ -156,13 +156,13 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
156
156
|
|
|
157
157
|
if (size !== chunk.size) {
|
|
158
158
|
throw new Error(
|
|
159
|
-
`Chunk ${chunk.i + 1}
|
|
159
|
+
`Chunk ${chunk.i + 1} has ${size} bytes, the manifest records ${chunk.size} bytes — mismatch.`,
|
|
160
160
|
)
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
if (sha256 !== chunk.sha256) {
|
|
164
164
|
throw new Error(
|
|
165
|
-
`Chunk ${chunk.i + 1}
|
|
165
|
+
`Chunk ${chunk.i + 1} has a sha256 that does not match the manifest. The download is kept at ${partial} for inspection.`,
|
|
166
166
|
)
|
|
167
167
|
}
|
|
168
168
|
}
|
|
@@ -170,28 +170,29 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
170
170
|
await handle.close()
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
//
|
|
174
|
-
//
|
|
173
|
+
// Last line of defence: if every chunk matched its sha256 and the file is still the
|
|
174
|
+
// wrong length, the layout went wrong somewhere. Better to fail than to rename a
|
|
175
|
+
// wrong file into the real one.
|
|
175
176
|
const written = await fs.stat(partial)
|
|
176
177
|
|
|
177
178
|
if (written.size !== manifest.size) {
|
|
178
179
|
throw new Error(
|
|
179
|
-
`
|
|
180
|
-
`
|
|
180
|
+
`The assembled file has ${written.size} bytes, the manifest records ${manifest.size} bytes — mismatch. ` +
|
|
181
|
+
`The download is kept at ${partial} for inspection.`,
|
|
181
182
|
)
|
|
182
183
|
}
|
|
183
184
|
|
|
184
185
|
await fs.rename(partial, target)
|
|
185
186
|
|
|
186
|
-
log(`\
|
|
187
|
+
log(`\nDone. Wrote ${formatBytes(manifest.size)} to ${target}`)
|
|
187
188
|
|
|
188
189
|
return { path: target, size: manifest.size }
|
|
189
190
|
} finally {
|
|
190
|
-
//
|
|
191
|
+
// A failing disconnect must not swallow the real error already on its way up.
|
|
191
192
|
try {
|
|
192
193
|
await disconnect(client)
|
|
193
194
|
} catch (err) {
|
|
194
|
-
warn(`\
|
|
195
|
+
warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`)
|
|
195
196
|
}
|
|
196
197
|
}
|
|
197
198
|
}
|
package/src/commands/upload.js
CHANGED
|
@@ -25,7 +25,7 @@ import { createProgress, formatBytes, formatDuration } from '../progress.js'
|
|
|
25
25
|
import { clearState, loadState, markChunkDone, saveState, stateDir, stateKey } from '../state.js'
|
|
26
26
|
import { uploadRange } from '../uploader.js'
|
|
27
27
|
|
|
28
|
-
//
|
|
28
|
+
// Above this threshold the wait must be spelled out, per spec §8.
|
|
29
29
|
const LONG_WAIT_MS = 60_000
|
|
30
30
|
|
|
31
31
|
async function realSendChunk(client, peer, { inputFile, fileName, caption }) {
|
|
@@ -65,12 +65,12 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
65
65
|
try {
|
|
66
66
|
stat = await fs.stat(absPath)
|
|
67
67
|
} catch (err) {
|
|
68
|
-
if (err.code === 'ENOENT') throw new Error(`File
|
|
68
|
+
if (err.code === 'ENOENT') throw new Error(`File does not exist: ${absPath}`)
|
|
69
69
|
throw err
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
if (!stat.isFile()) {
|
|
73
|
-
throw new Error(`${absPath}
|
|
73
|
+
throw new Error(`${absPath} is not a file.`)
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
const config = await loadConfig(configDir)
|
|
@@ -80,9 +80,9 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
80
80
|
|
|
81
81
|
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) {
|
|
82
82
|
throw new Error(
|
|
83
|
-
|
|
84
|
-
`
|
|
85
|
-
'
|
|
83
|
+
`Invalid --concurrency: "${options.concurrency}". ` +
|
|
84
|
+
`Must be an integer from 1 to ${MAX_CONCURRENCY} — each slot holds a 512KB part in RAM ` +
|
|
85
|
+
'and Telegram answers with FLOOD_WAIT if too many requests go out at once.',
|
|
86
86
|
)
|
|
87
87
|
}
|
|
88
88
|
|
|
@@ -95,9 +95,9 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
95
95
|
if (resuming && state.chat !== String(chat)) {
|
|
96
96
|
const stateFile = path.join(stateDir(configDir), `${key}.json`)
|
|
97
97
|
throw new Error(
|
|
98
|
-
`
|
|
99
|
-
`
|
|
100
|
-
|
|
98
|
+
`This unfinished backup is going to ${state.chat}, but the current command targets ${chat} — ` +
|
|
99
|
+
`a single backup cannot be split across two destinations. Run again without --to to keep ` +
|
|
100
|
+
`sending to ${state.chat}, or delete ${stateFile} and run again to start a new backup in ${chat}.`,
|
|
101
101
|
)
|
|
102
102
|
}
|
|
103
103
|
|
|
@@ -121,33 +121,33 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
121
121
|
const log = silent ? () => {} : (line) => console.log(line)
|
|
122
122
|
const warn = silent ? () => {} : writeErr
|
|
123
123
|
|
|
124
|
-
//
|
|
125
|
-
//
|
|
124
|
+
// Retries and FLOOD_WAIT must be announced: a silent FLOOD_WAIT_3600 leaves the user
|
|
125
|
+
// staring at a frozen progress bar for an hour, assuming the process has hung.
|
|
126
126
|
function onRetry(err, attempt, delayMs) {
|
|
127
127
|
if (delayMs > LONG_WAIT_MS) {
|
|
128
128
|
warn(
|
|
129
|
-
`\nTelegram
|
|
130
|
-
`(${err.message}). data-ark
|
|
129
|
+
`\nTelegram wants ${formatDuration(delayMs / 1000)} of waiting before the next send ` +
|
|
130
|
+
`(${err.message}). data-ark is waiting and will carry on by itself, leave it running.\n`,
|
|
131
131
|
)
|
|
132
132
|
return
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
warn(
|
|
136
|
-
`\
|
|
136
|
+
`\nTemporary error (${err.message}), retry ${attempt} in ` +
|
|
137
137
|
`${formatDuration(delayMs / 1000)}.\n`,
|
|
138
138
|
)
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
log(`Backup ${state.id}`)
|
|
142
|
-
log(`File ${absPath} (${formatBytes(stat.size)}, ${chunks.length}
|
|
143
|
-
log(
|
|
142
|
+
log(`File ${absPath} (${formatBytes(stat.size)}, ${chunks.length} chunks)`)
|
|
143
|
+
log(`To ${chat}\n`)
|
|
144
144
|
|
|
145
145
|
const client = await connect(config)
|
|
146
146
|
|
|
147
147
|
try {
|
|
148
148
|
for (const chunk of chunks) {
|
|
149
149
|
if (state.done[String(chunk.i)]) {
|
|
150
|
-
log(`Chunk ${chunk.i + 1}/${chunks.length}
|
|
150
|
+
log(`Chunk ${chunk.i + 1}/${chunks.length} already uploaded, skipping.`)
|
|
151
151
|
continue
|
|
152
152
|
}
|
|
153
153
|
|
|
@@ -193,17 +193,17 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
196
|
+
// The file can be overwritten mid-upload — with a 50GB file an hour passes between
|
|
197
|
+
// the first and last chunk. The manifest would then describe a hybrid file that never
|
|
198
|
+
// existed: restore still matches every sha256, but the data is garbage.
|
|
199
199
|
const after = await fs.stat(absPath)
|
|
200
200
|
|
|
201
201
|
if (after.size !== stat.size || after.mtimeMs !== stat.mtimeMs) {
|
|
202
202
|
throw new Error(
|
|
203
|
-
`${absPath}
|
|
204
|
-
`(
|
|
205
|
-
'
|
|
206
|
-
'
|
|
203
|
+
`${absPath} changed during the upload ` +
|
|
204
|
+
`(size ${stat.size} → ${after.size}, mtime ${stat.mtimeMs} → ${after.mtimeMs}). ` +
|
|
205
|
+
'This backup mixes old and new data and cannot be trusted — data-ark is not sending the manifest. ' +
|
|
206
|
+
'Wait until the file settles, then run again to create a new backup.',
|
|
207
207
|
)
|
|
208
208
|
}
|
|
209
209
|
|
|
@@ -223,15 +223,15 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
223
223
|
|
|
224
224
|
await clearState(key, configDir)
|
|
225
225
|
|
|
226
|
-
log(`\
|
|
226
|
+
log(`\nDone. Restore with:\n npx data-ark restore ${state.id}`)
|
|
227
227
|
|
|
228
228
|
return { id: state.id, chunks: chunks.length }
|
|
229
229
|
} finally {
|
|
230
|
-
//
|
|
230
|
+
// A failing disconnect must not swallow the real error already on its way up.
|
|
231
231
|
try {
|
|
232
232
|
await disconnect(client)
|
|
233
233
|
} catch (err) {
|
|
234
|
-
warn(`\
|
|
234
|
+
warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`)
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
237
|
}
|
package/src/config.js
CHANGED
|
@@ -22,7 +22,7 @@ export async function loadConfig(dir = defaultConfigDir()) {
|
|
|
22
22
|
try {
|
|
23
23
|
return JSON.parse(raw)
|
|
24
24
|
} catch {
|
|
25
|
-
throw new Error(`
|
|
25
|
+
throw new Error(`Corrupt config file: ${file}. Delete it and run "data-ark login" again.`)
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
package/src/downloader.js
CHANGED
|
@@ -19,17 +19,17 @@ export async function downloadToFile(client, message, fd, { offset, onProgress }
|
|
|
19
19
|
const document = message?.media?.document
|
|
20
20
|
|
|
21
21
|
if (!document) {
|
|
22
|
-
throw new Error(`Message ${message?.id}
|
|
22
|
+
throw new Error(`Message ${message?.id} has no file attached.`)
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
if (!Number.isFinite(offset)) {
|
|
26
|
-
throw new Error(`offset
|
|
26
|
+
throw new Error(`offset must be a finite number, got: ${offset}`)
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
const hash = createHash('sha256')
|
|
30
30
|
let written = 0
|
|
31
31
|
|
|
32
|
-
for await (const buffer of client.iterDownload({ file:
|
|
32
|
+
for await (const buffer of client.iterDownload({ file: message.media, requestSize: PART_SIZE })) {
|
|
33
33
|
await writeExactly(fd, buffer, offset + written)
|
|
34
34
|
hash.update(buffer)
|
|
35
35
|
written += buffer.length
|
package/src/manifest.js
CHANGED
|
@@ -42,48 +42,49 @@ export function parseManifest(input) {
|
|
|
42
42
|
try {
|
|
43
43
|
manifest = JSON.parse(text)
|
|
44
44
|
} catch {
|
|
45
|
-
throw new Error('
|
|
45
|
+
throw new Error('Cannot read manifest: content is not valid JSON.')
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
if (manifest.v !== MANIFEST_VERSION) {
|
|
49
49
|
throw new Error(
|
|
50
|
-
`Manifest
|
|
50
|
+
`Manifest uses version ${manifest.v}, this build of data-ark only understands version ${MANIFEST_VERSION}.`,
|
|
51
51
|
)
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
if (!Array.isArray(manifest.chunks) || manifest.chunks.length === 0) {
|
|
55
|
-
throw new Error('Manifest
|
|
55
|
+
throw new Error('Manifest has no chunk list.')
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
manifest.chunks.forEach((chunk, index) => {
|
|
59
59
|
if (chunk.i !== index) {
|
|
60
|
-
throw new Error(`Manifest
|
|
60
|
+
throw new Error(`Manifest is missing chunk ${index}: the chunk list is not contiguous.`)
|
|
61
61
|
}
|
|
62
62
|
})
|
|
63
63
|
|
|
64
64
|
const expectedChunks = Math.ceil(manifest.size / manifest.chunkSize)
|
|
65
65
|
if (manifest.chunks.length !== expectedChunks) {
|
|
66
|
-
throw new Error(`Manifest
|
|
66
|
+
throw new Error(`Manifest is missing ${expectedChunks - manifest.chunks.length} chunk(s).`)
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
const total = manifest.chunks.reduce((sum, chunk) => sum + chunk.size, 0)
|
|
70
70
|
if (total !== manifest.size) {
|
|
71
71
|
throw new Error(
|
|
72
|
-
`
|
|
72
|
+
`Chunk sizes add up to ${total}, but the manifest records a file size of ${manifest.size}.`,
|
|
73
73
|
)
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
// Restore
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
76
|
+
// Restore writes chunk i at exactly offset i * chunkSize, so the layout must be
|
|
77
|
+
// uniform: every chunk is chunkSize, except the last one which is the remainder.
|
|
78
|
+
// A correct total with individually wrong sizes yields a file with a hole or
|
|
79
|
+
// extra length while every per-chunk sha256 still matches — silently wrong data,
|
|
80
|
+
// precisely what data-ark must never produce.
|
|
80
81
|
manifest.chunks.forEach((chunk, index) => {
|
|
81
82
|
const expected = Math.min(manifest.chunkSize, manifest.size - index * manifest.chunkSize)
|
|
82
83
|
if (chunk.size !== expected) {
|
|
83
84
|
throw new Error(
|
|
84
|
-
`
|
|
85
|
-
`${manifest.chunkSize}
|
|
86
|
-
'
|
|
85
|
+
`Manifest records ${chunk.size} bytes for chunk ${index + 1}, but a layout of ` +
|
|
86
|
+
`${manifest.chunkSize} bytes per chunk requires ${expected} bytes. ` +
|
|
87
|
+
'This manifest describes the wrong chunk positions; restoring it would produce a corrupt file.',
|
|
87
88
|
)
|
|
88
89
|
}
|
|
89
90
|
})
|
package/src/progress.js
CHANGED
|
@@ -33,13 +33,13 @@ export function renderProgress({ done, total, elapsedMs, label, width = 24 }) {
|
|
|
33
33
|
const bar = `${'█'.repeat(filled)}${'░'.repeat(width - filled)}`
|
|
34
34
|
|
|
35
35
|
const bytesPerSecond = elapsedMs > 0 ? done / (elapsedMs / 1000) : 0
|
|
36
|
-
//
|
|
36
|
+
// Clamp to 0: done can overshoot total (one extra tick) and a negative ETA is meaningless.
|
|
37
37
|
const remaining = bytesPerSecond > 0 ? Math.max(0, (total - done) / bytesPerSecond) : Infinity
|
|
38
38
|
|
|
39
39
|
const percent = String(Math.floor(ratio * 100)).padStart(3)
|
|
40
40
|
const speed = `${formatBytes(Math.round(bytesPerSecond))}/s`
|
|
41
41
|
|
|
42
|
-
return `${label} ${bar} ${percent}% ${formatBytes(done)}/${formatBytes(total)} ${speed}
|
|
42
|
+
return `${label} ${bar} ${percent}% ${formatBytes(done)}/${formatBytes(total)} ${speed} ETA ${formatDuration(remaining)}`
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export function createProgress({
|
package/src/uploader.js
CHANGED
|
@@ -10,9 +10,9 @@ import { withRetry } from './retry.js'
|
|
|
10
10
|
|
|
11
11
|
const read = promisify(readCallback)
|
|
12
12
|
|
|
13
|
-
// Telegram
|
|
14
|
-
//
|
|
15
|
-
// node_modules/telegram/client/uploads.js), data-ark
|
|
13
|
+
// Telegram splits its upload API by file size: only files above 10MB may use the
|
|
14
|
+
// "big" family. GramJS picks the same threshold (LARGE_FILE_THRESHOLD in
|
|
15
|
+
// node_modules/telegram/client/uploads.js), and data-ark follows it.
|
|
16
16
|
export const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024
|
|
17
17
|
|
|
18
18
|
async function readExactly(fd, length, position) {
|
|
@@ -22,7 +22,7 @@ async function readExactly(fd, length, position) {
|
|
|
22
22
|
while (filled < length) {
|
|
23
23
|
const { bytesRead } = await read(fd, buffer, filled, length - filled, position + filled)
|
|
24
24
|
if (bytesRead === 0) {
|
|
25
|
-
throw new Error(
|
|
25
|
+
throw new Error(`Short read: needed ${length} bytes at offset ${position} but the file ended.`)
|
|
26
26
|
}
|
|
27
27
|
filled += bytesRead
|
|
28
28
|
}
|
|
@@ -45,7 +45,7 @@ export async function uploadRange(client, fd, options) {
|
|
|
45
45
|
|
|
46
46
|
if (totalParts > MAX_PARTS) {
|
|
47
47
|
throw new Error(
|
|
48
|
-
`
|
|
48
|
+
`This byte range needs ${totalParts} parts, but Telegram accepts at most 4000 parts per file.`,
|
|
49
49
|
)
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -73,7 +73,7 @@ export async function uploadRange(client, fd, options) {
|
|
|
73
73
|
let sendFailed = false
|
|
74
74
|
let readError = null
|
|
75
75
|
|
|
76
|
-
//
|
|
76
|
+
// Read sequentially so the hash sees parts in order, but send in parallel.
|
|
77
77
|
try {
|
|
78
78
|
for (let part = start; part < end; part += 1) {
|
|
79
79
|
const partOffset = part * partSize
|
|
@@ -82,17 +82,18 @@ export async function uploadRange(client, fd, options) {
|
|
|
82
82
|
|
|
83
83
|
hash.update(bytes)
|
|
84
84
|
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
85
|
+
// Attach the handler when the promise is created rather than waiting for the
|
|
86
|
+
// Promise.all at the end of the batch: if readExactly throws on a later part,
|
|
87
|
+
// an already-pushed promise with no handler becomes an unhandledRejection and
|
|
88
|
+
// hides the real error.
|
|
88
89
|
sending.push(
|
|
89
90
|
withRetry(() => client.invoke(partRequest(part, bytes)), retryOptions).then(
|
|
90
91
|
() => onProgress?.(partLength),
|
|
91
92
|
(err) => {
|
|
92
|
-
//
|
|
93
|
-
// (undefined/null),
|
|
94
|
-
//
|
|
95
|
-
//
|
|
93
|
+
// Not `sendError ??= err`: if the rejection reason is falsy
|
|
94
|
+
// (undefined/null), that assignment still sets sendError to a falsy
|
|
95
|
+
// value and the `if (sendError)` below reads as "no error" — swallowing
|
|
96
|
+
// a failed send and turning it into a fake success.
|
|
96
97
|
if (!sendFailed) {
|
|
97
98
|
sendFailed = true
|
|
98
99
|
sendError = err
|
|
@@ -105,8 +106,8 @@ export async function uploadRange(client, fd, options) {
|
|
|
105
106
|
readError = err
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
//
|
|
109
|
-
//
|
|
109
|
+
// Every promise in `sending` already absorbed its own error above, so they all
|
|
110
|
+
// resolve; this await only guarantees no request is still in flight when we throw.
|
|
110
111
|
await Promise.all(sending)
|
|
111
112
|
|
|
112
113
|
if (readError) throw readError
|