data-ark 0.1.2 → 0.1.3

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 CHANGED
@@ -26,7 +26,7 @@ data-ark signs in with your own Telegram account (MTProto), not a bot. That is a
26
26
  | Flag | Default | Meaning |
27
27
  |---|---|---|
28
28
  | `--to <chat>` | the remembered destination | `@username`, `-100123…`, or `me`. `upload` and `status` remember it; `list` and `restore` only look there. A negative channel id works either way: `--to -100123…` or `--to=-100123…` |
29
- | `--chunk-size <n>` | `1800MB` | e.g. `1.8GB`, `500MB`. Hard ceiling 1950MB. |
29
+ | `--chunk-size <n>` | `1800MB` | e.g. `1.8GB`, `500MB`. Hard ceiling 1950MB. An unfinished backup keeps the size it started with. |
30
30
  | `--concurrency <n>` | `8` | 512KB parts sent in parallel. An integer from 1 to 64. |
31
31
  | `--out <path>` | the basename from the manifest | Where to write the restored file; relative paths resolve against the current directory |
32
32
  | `--limit <n>` | `20` | How many backups `list` shows, newest first |
@@ -73,7 +73,10 @@ Every run mints a `backupId`. The file is read directly by offset — no tempora
73
73
  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:
74
74
 
75
75
  - 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.
76
- - Running again with a different `--chunk-size` is treated as an entirely new backup (new backup id), not a resume.
76
+ - Running again **without** `--chunk-size` resumes at the size the backup started with, whatever the default is today.
77
+ - Running again **with** a `--chunk-size` that differs from that size makes data-ark **refuse to run**, for the same reason as `--to`: the chunks already in the chat were cut that way and cannot be re-cut. Drop the flag to carry on, or delete the state file to start a new backup — which leaves the chunks already sent in the chat with nothing pointing at them.
78
+
79
+ `Ctrl-C` during an upload names the backup it was working on, so `data-ark status` and a later `restore` have something to go on. data-ark keeps the **20 most recent** unfinished backups in `~/.data-ark/state/`; starting a new one past that drops the oldest record and says which id it dropped. Only the local record goes — the chunks that backup sent stay in the chat, searchable by that id, but it can no longer be resumed.
77
80
 
78
81
  **Restore keeps no state to resume from.** Pressing `Ctrl-C` mid-restore saves nothing — running again starts over.
79
82
 
package/bin/data-ark.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { route, HELP } from '../src/cli.js'
2
+ import { route, HELP, interruptMessage } from '../src/cli.js'
3
3
  import { runLogin } from '../src/commands/login.js'
4
4
  import { runList } from '../src/commands/list.js'
5
5
  import { runLogout } from '../src/commands/logout.js'
@@ -12,22 +12,12 @@ const SIGINT_EXIT_CODE = 130
12
12
 
13
13
  // Which command is running when Ctrl-C arrives — each one tells a different truth
14
14
  // about whether progress was saved, so we need to know which to pick the right line.
15
+ // The backup id arrives a moment later, once upload knows which backup this run is.
15
16
  let currentCommand = null
16
-
17
- function sigintMessage(command) {
18
- if (command === 'upload') {
19
- return '\nStopped. Progress is saved, run the same command again to continue.\n'
20
- }
21
-
22
- if (command === 'restore') {
23
- return '\nStopped. Download progress is not saved, running again starts over.\n'
24
- }
25
-
26
- return '\nStopped.\n'
27
- }
17
+ let currentBackupId = null
28
18
 
29
19
  process.on('SIGINT', () => {
30
- process.stderr.write(sigintMessage(currentCommand))
20
+ process.stderr.write(interruptMessage(currentCommand, { backupId: currentBackupId }))
31
21
  process.exit(SIGINT_EXIT_CODE)
32
22
  })
33
23
 
@@ -70,7 +60,11 @@ async function main() {
70
60
  return
71
61
 
72
62
  case 'upload':
73
- await runUpload(parsed.args[0], parsed.options)
63
+ await runUpload(parsed.args[0], parsed.options, {
64
+ onBackupId: (id) => {
65
+ currentBackupId = id
66
+ },
67
+ })
74
68
  return
75
69
 
76
70
  case 'restore':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "data-ark",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Split large files into chunks and store them on Telegram",
5
5
  "keywords": [
6
6
  "telegram",
package/src/cli.js CHANGED
@@ -27,6 +27,7 @@ Options:
27
27
  --to <chat> Destination: @username, -100123..., or me. upload and status
28
28
  remember it; list and restore only look there.
29
29
  --chunk-size <n> Size of each chunk, default 1800MB. Examples: 1.8GB, 500MB.
30
+ An unfinished backup keeps the size it started with.
30
31
  --concurrency <n> 512KB parts sent in parallel, default 8, max 64.
31
32
  --out <path> Where to write the restored file. Defaults to the basename in the manifest.
32
33
  --limit <n> How many backups list shows, default 20.
@@ -34,6 +35,27 @@ Options:
34
35
  -h, --help Show this help.
35
36
  `
36
37
 
38
+ // What Ctrl-C means depends on the command that was running: upload has written every
39
+ // finished chunk to a state file, restore has not. Naming the backup matters because the
40
+ // id is what `status` lists and what a later `restore` needs — the chunks are already in
41
+ // the chat under that id, whether or not this run ever finishes.
42
+ export function interruptMessage(command, { backupId } = {}) {
43
+ if (command === 'upload') {
44
+ const backup = backupId ? `Backup ${backupId} is saved` : 'Progress is saved'
45
+
46
+ return (
47
+ `\n${backup} — run the same command again to continue, ` +
48
+ 'or "npx data-ark status" to see what is left.\n'
49
+ )
50
+ }
51
+
52
+ if (command === 'restore') {
53
+ return '\nStopped. Download progress is not saved, running again starts over.\n'
54
+ }
55
+
56
+ return '\nStopped.\n'
57
+ }
58
+
37
59
  // A channel id is negative, and typing it separated by a space is the natural reflex —
38
60
  // but parseArgs rejects any value starting with a dash as ambiguous. Join the pair itself
39
61
  // so `--to -100123` works like `--to=-100123`. Only a bare negative integer qualifies, so
@@ -7,7 +7,11 @@ import { closeQuietly, connect as realConnect, requireChat, searchDocuments } fr
7
7
  import { defaultConfigDir, loadConfig } from '../config.js'
8
8
  import { downloadToFile } from '../downloader.js'
9
9
  import { manifestFileName, parseManifest } from '../manifest.js'
10
- import { createProgress, formatBytes } from '../progress.js'
10
+ import { createProgress, formatBytes, formatDuration } from '../progress.js'
11
+
12
+ // Anything past a minute of waiting needs saying out loud; below that the pause is shorter
13
+ // than the time a user would spend wondering about it.
14
+ const LONG_WAIT_MS = 60_000
11
15
 
12
16
  async function realSearchManifest(client, peer, backupId) {
13
17
  const wanted = manifestFileName(backupId)
@@ -25,8 +29,8 @@ async function realGetMessage(client, peer, msgId) {
25
29
  return message ?? null
26
30
  }
27
31
 
28
- export async function realDownloadChunk(client, message, handle, offset, onProgress) {
29
- return await downloadToFile(client, message, handle.fd, { offset, onProgress })
32
+ export async function realDownloadChunk(client, message, handle, offset, onProgress, retryOptions) {
33
+ return await downloadToFile(client, message, handle.fd, { offset, onProgress, retryOptions })
30
34
  }
31
35
 
32
36
  // manifest.name comes from data downloaded off Telegram — don't trust it when picking
@@ -63,15 +67,35 @@ export async function runRestore(backupId, options = {}, deps = {}) {
63
67
  getMessage = realGetMessage,
64
68
  downloadChunk = realDownloadChunk,
65
69
  confirm = askConfirm,
70
+ retryOptions = {},
66
71
  writeErr = (line) => process.stderr.write(line),
72
+ log: writeLog = (line) => console.log(line),
67
73
  silent = false,
68
74
  } = deps
69
75
 
70
76
  const config = await loadConfig(configDir)
71
77
  const chat = requireChat(options, config)
72
- const log = silent ? () => {} : (line) => console.log(line)
78
+ const log = silent ? () => {} : writeLog
73
79
  const warn = silent ? () => {} : writeErr
74
80
 
81
+ // A restore keeps no progress file, so a part that comes back -503 is retried rather than
82
+ // thrown away — and a retry nobody is told about is indistinguishable from a hung transfer,
83
+ // because the progress bar simply stops moving while the wait runs.
84
+ function onRetry(err, attempt, delayMs) {
85
+ if (delayMs > LONG_WAIT_MS) {
86
+ warn(
87
+ `\nTelegram wants ${formatDuration(delayMs / 1000)} of waiting before the next part ` +
88
+ `(${err.message}). data-ark is waiting and will carry on by itself, leave it running.\n`,
89
+ )
90
+ return
91
+ }
92
+
93
+ warn(
94
+ `\nTemporary error (${err.message}), retry ${attempt} in ` +
95
+ `${formatDuration(delayMs / 1000)}.\n`,
96
+ )
97
+ }
98
+
75
99
  const client = await connect(config, { verbose: options.verbose })
76
100
 
77
101
  try {
@@ -134,6 +158,7 @@ export async function runRestore(backupId, options = {}, deps = {}) {
134
158
  handle,
135
159
  chunk.i * manifest.chunkSize,
136
160
  progress.advance,
161
+ { ...retryOptions, onRetry },
137
162
  )
138
163
 
139
164
  progress.finish()
@@ -23,7 +23,16 @@ import {
23
23
  serializeManifest,
24
24
  } from '../manifest.js'
25
25
  import { createProgress, formatBytes, formatDuration } from '../progress.js'
26
- import { clearState, loadState, markChunkDone, saveState, stateFile, stateKey } from '../state.js'
26
+ import {
27
+ MAX_STATES,
28
+ clearState,
29
+ loadState,
30
+ markChunkDone,
31
+ pruneStates,
32
+ saveState,
33
+ stateFile,
34
+ stateKey,
35
+ } from '../state.js'
27
36
  import { uploadRange } from '../uploader.js'
28
37
 
29
38
  // Above this threshold the wait must be spelled out, per spec §8.
@@ -63,6 +72,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
63
72
  retryOptions = {},
64
73
  writeErr = (line) => process.stderr.write(line),
65
74
  silent = false,
75
+ onBackupId = () => {},
66
76
  } = deps
67
77
 
68
78
  const absPath = path.resolve(filePath)
@@ -81,7 +91,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
81
91
 
82
92
  const config = await loadConfig(configDir)
83
93
  const chat = requireChat(options, config)
84
- const chunkSize = options['chunk-size'] ? parseSize(options['chunk-size']) : DEFAULT_CHUNK_SIZE
94
+ const requestedChunkSize = options['chunk-size'] ? parseSize(options['chunk-size']) : null
85
95
  const concurrency = options.concurrency ? Number(options.concurrency) : DEFAULT_CONCURRENCY
86
96
 
87
97
  if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) {
@@ -92,11 +102,28 @@ export async function runUpload(filePath, options = {}, deps = {}) {
92
102
  )
93
103
  }
94
104
 
95
- const chunks = planChunks(stat.size, chunkSize)
96
105
  const key = stateKey(absPath, stat.size, stat.mtimeMs)
97
106
 
98
107
  let state = await loadState(key, configDir)
99
- const resuming = Boolean(state) && state.chunkSize === chunkSize
108
+
109
+ // The chunks already in the chat were cut at the size this backup started with, and
110
+ // nothing can re-cut them. Carrying on at a different size would abandon every one of
111
+ // them in the chat, where data-ark can no longer find them — so an unfinished backup
112
+ // keeps its own chunk size, and a flag that disagrees is refused rather than obeyed.
113
+ if (state && requestedChunkSize !== null && requestedChunkSize !== state.chunkSize) {
114
+ const file = stateFile(key, configDir)
115
+ throw new Error(
116
+ `This unfinished backup is cut into ${formatBytes(state.chunkSize)} chunks, but ` +
117
+ `--chunk-size asks for ${formatBytes(requestedChunkSize)} — the chunks already in ` +
118
+ `${state.chat} cannot be re-cut. Run again without --chunk-size to carry on, or delete ` +
119
+ `${file} and run again to start a new backup, which leaves the chunks already sent ` +
120
+ 'sitting in the chat with nothing to point at them.',
121
+ )
122
+ }
123
+
124
+ const chunkSize = state ? state.chunkSize : (requestedChunkSize ?? DEFAULT_CHUNK_SIZE)
125
+ const chunks = planChunks(stat.size, chunkSize)
126
+ const resuming = Boolean(state)
100
127
 
101
128
  if (resuming && state.chat !== String(chat)) {
102
129
  const file = stateFile(key, configDir)
@@ -122,8 +149,21 @@ export async function runUpload(filePath, options = {}, deps = {}) {
122
149
  done: {},
123
150
  }
124
151
  await saveState(key, state, configDir)
152
+
153
+ // Only a new backup adds to the directory, so this is the one place it can grow.
154
+ // The report goes out even when the caller asked for silence: this is not narration
155
+ // about a transfer, it is data-ark dropping the only record of someone else's chunks.
156
+ for (const gone of await pruneStates(configDir)) {
157
+ writeErr(
158
+ `\nDropped the record of unfinished backup ${gone.id}: data-ark keeps the ` +
159
+ `${MAX_STATES} most recent. The chunks it sent are still in ${gone.chat}, ` +
160
+ 'searchable by that id, but that backup can no longer be resumed.\n',
161
+ )
162
+ }
125
163
  }
126
164
 
165
+ onBackupId(state.id)
166
+
127
167
  const log = silent ? () => {} : (line) => console.log(line)
128
168
  const warn = silent ? () => {} : writeErr
129
169
 
package/src/downloader.js CHANGED
@@ -2,7 +2,10 @@ import { createHash } from 'node:crypto'
2
2
  import { write as writeCallback } from 'node:fs'
3
3
  import { promisify } from 'node:util'
4
4
 
5
+ import { returnBigInt } from 'telegram/Helpers.js'
6
+
5
7
  import { PART_SIZE } from './chunking.js'
8
+ import { withRetry } from './retry.js'
6
9
 
7
10
  const write = promisify(writeCallback)
8
11
 
@@ -15,7 +18,7 @@ async function writeExactly(fd, buffer, position) {
15
18
  }
16
19
  }
17
20
 
18
- export async function downloadToFile(client, message, fd, { offset, onProgress } = {}) {
21
+ export async function downloadToFile(client, message, fd, { offset, onProgress, retryOptions } = {}) {
19
22
  const document = message?.media?.document
20
23
 
21
24
  if (!document) {
@@ -29,11 +32,44 @@ export async function downloadToFile(client, message, fd, { offset, onProgress }
29
32
  const hash = createHash('sha256')
30
33
  let written = 0
31
34
 
32
- for await (const buffer of client.iterDownload({ file: message.media, requestSize: PART_SIZE })) {
33
- await writeExactly(fd, buffer, offset + written)
34
- hash.update(buffer)
35
- written += buffer.length
36
- onProgress?.(buffer.length)
35
+ // A chunk is thousands of separate part requests and any one of them can come back
36
+ // -503, so a stream that breaks has to be picked up rather than abandoned — there is no
37
+ // resume file for a download, and giving up throws away every byte fetched so far.
38
+ // Restarting the iterator at `offset + written` is what makes that safe: bytes are
39
+ // hashed in the order they are written and `written` only moves once a buffer has been
40
+ // both written and hashed, so the resumed stream continues the same digest.
41
+ //
42
+ // The two offsets are not the same number and must not be confused: `written` is a
43
+ // position inside the document, which is where the stream resumes, while `offset +
44
+ // written` is a position inside the file being assembled, which is where the bytes land.
45
+ // Every chunk after the first has a non-zero `offset`, so mixing them up reads the wrong
46
+ // part of the document — caught by sha256, but only after re-downloading the whole chunk.
47
+ async function streamFromWhereWeStopped() {
48
+ for await (const buffer of client.iterDownload({
49
+ file: message.media,
50
+ offset: returnBigInt(written),
51
+ requestSize: PART_SIZE,
52
+ })) {
53
+ await writeExactly(fd, buffer, offset + written)
54
+ hash.update(buffer)
55
+ written += buffer.length
56
+ onProgress?.(buffer.length)
57
+ }
58
+ }
59
+
60
+ // One attempt budget for the whole chunk would be the wrong unit: 1800MB is 3600 requests,
61
+ // and five failures spread across them is a healthy download, not a broken one. So a budget
62
+ // only ends the restore when it is spent without gaining a single byte. Every outer pass
63
+ // must gain at least one byte to earn another, which is what bounds the loop.
64
+ for (;;) {
65
+ const before = written
66
+
67
+ try {
68
+ await withRetry(streamFromWhereWeStopped, retryOptions)
69
+ break
70
+ } catch (err) {
71
+ if (written === before) throw err
72
+ }
37
73
  }
38
74
 
39
75
  return { sha256: hash.digest('hex'), size: written }
package/src/state.js CHANGED
@@ -44,6 +44,55 @@ export async function clearState(key, configDir = defaultConfigDir()) {
44
44
  }
45
45
  }
46
46
 
47
+ // A state file is only useful while its backup can still be resumed, and nothing ever
48
+ // removes one whose file was edited since: the key includes mtime, so that state can never
49
+ // match again. Left alone the directory only grows, and with it the report `status` prints.
50
+ export const MAX_STATES = 20
51
+
52
+ // The newest states are the ones worth keeping, and a state file is rewritten every time a
53
+ // chunk lands, so its mtime is when this backup last made progress. Returns the states that
54
+ // were dropped: the caller says their ids out loud, because after this the id is the only
55
+ // way left to find those chunks in the chat.
56
+ export async function pruneStates(configDir = defaultConfigDir(), keep = MAX_STATES) {
57
+ let names
58
+ try {
59
+ names = await fs.readdir(stateDir(configDir))
60
+ } catch (err) {
61
+ if (err.code === 'ENOENT') return []
62
+ throw err
63
+ }
64
+
65
+ const files = []
66
+
67
+ for (const name of names) {
68
+ if (!name.endsWith('.json')) continue
69
+
70
+ const file = path.join(stateDir(configDir), name)
71
+
72
+ try {
73
+ const stat = await fs.stat(file)
74
+ files.push({ key: name.slice(0, -'.json'.length), file, mtimeMs: stat.mtimeMs })
75
+ } catch (err) {
76
+ // Gone between readdir and stat: nothing left to prune.
77
+ if (err.code !== 'ENOENT') throw err
78
+ }
79
+ }
80
+
81
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs)
82
+
83
+ const dropped = []
84
+
85
+ for (const { key, file } of files.slice(keep)) {
86
+ // Read before unlink: a file that cannot be read back, or that carries no id, is
87
+ // still pruned — it just cannot be named, and a report naming nothing helps no one.
88
+ const state = await loadState(key, configDir)
89
+ await fs.unlink(file)
90
+ if (state?.id) dropped.push(state)
91
+ }
92
+
93
+ return dropped
94
+ }
95
+
47
96
  // status needs every unfinished backup at once. A state file that cannot be read is skipped
48
97
  // rather than fatal, for the same reason loadState returns null: one corrupt file must not
49
98
  // hide the other backups still waiting to be finished.