data-ark 0.1.1 → 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.
@@ -3,32 +3,21 @@ import path from 'node:path'
3
3
  import readline from 'node:readline/promises'
4
4
  import { stdin, stdout } from 'node:process'
5
5
 
6
- import { Api } from 'telegram'
7
-
8
- import { connect as realConnect, requireChat } from '../client.js'
6
+ import { closeQuietly, connect as realConnect, requireChat, searchDocuments } from '../client.js'
9
7
  import { defaultConfigDir, loadConfig } from '../config.js'
10
8
  import { downloadToFile } from '../downloader.js'
11
9
  import { manifestFileName, parseManifest } from '../manifest.js'
12
- import { createProgress, formatBytes } from '../progress.js'
10
+ import { createProgress, formatBytes, formatDuration } from '../progress.js'
13
11
 
14
- function documentFileName(message) {
15
- const attributes = message?.media?.document?.attributes ?? []
16
- const named = attributes.find((a) => a instanceof Api.DocumentAttributeFilename)
17
- return named?.fileName ?? null
18
- }
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
19
15
 
20
16
  async function realSearchManifest(client, peer, backupId) {
21
17
  const wanted = manifestFileName(backupId)
18
+ const found = await searchDocuments(client, peer, { search: backupId, limit: 100 })
22
19
 
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
- const messages = await client.getMessages(peer, {
26
- search: backupId,
27
- filter: new Api.InputMessagesFilterDocument(),
28
- limit: 100,
29
- })
30
-
31
- return messages.find((m) => documentFileName(m) === wanted) ?? null
20
+ return found.find((doc) => doc.fileName === wanted)?.message ?? null
32
21
  }
33
22
 
34
23
  async function realReadMessageBytes(client, message) {
@@ -40,8 +29,8 @@ async function realGetMessage(client, peer, msgId) {
40
29
  return message ?? null
41
30
  }
42
31
 
43
- export async function realDownloadChunk(client, message, handle, offset, onProgress) {
44
- 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 })
45
34
  }
46
35
 
47
36
  // manifest.name comes from data downloaded off Telegram — don't trust it when picking
@@ -78,16 +67,36 @@ export async function runRestore(backupId, options = {}, deps = {}) {
78
67
  getMessage = realGetMessage,
79
68
  downloadChunk = realDownloadChunk,
80
69
  confirm = askConfirm,
70
+ retryOptions = {},
81
71
  writeErr = (line) => process.stderr.write(line),
72
+ log: writeLog = (line) => console.log(line),
82
73
  silent = false,
83
74
  } = deps
84
75
 
85
76
  const config = await loadConfig(configDir)
86
77
  const chat = requireChat(options, config)
87
- const log = silent ? () => {} : (line) => console.log(line)
78
+ const log = silent ? () => {} : writeLog
88
79
  const warn = silent ? () => {} : writeErr
89
80
 
90
- const client = await connect(config)
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
+
99
+ const client = await connect(config, { verbose: options.verbose })
91
100
 
92
101
  try {
93
102
  const manifestMessage = await searchManifest(client, chat, backupId)
@@ -136,13 +145,12 @@ export async function runRestore(backupId, options = {}, deps = {}) {
136
145
  )
137
146
  }
138
147
 
139
- const progress = silent
140
- ? { advance: () => {}, finish: () => {} }
141
- : createProgress({
142
- total: chunk.size,
143
- label: `Chunk ${chunk.i + 1}/${manifest.chunks.length}`,
144
- write: writeErr,
145
- })
148
+ // warn is already the no-op when silent, and createProgress draws through nothing else.
149
+ const progress = createProgress({
150
+ total: chunk.size,
151
+ label: `Chunk ${chunk.i + 1}/${manifest.chunks.length}`,
152
+ write: warn,
153
+ })
146
154
 
147
155
  const { sha256, size } = await downloadChunk(
148
156
  client,
@@ -150,6 +158,7 @@ export async function runRestore(backupId, options = {}, deps = {}) {
150
158
  handle,
151
159
  chunk.i * manifest.chunkSize,
152
160
  progress.advance,
161
+ { ...retryOptions, onRetry },
153
162
  )
154
163
 
155
164
  progress.finish()
@@ -188,11 +197,8 @@ export async function runRestore(backupId, options = {}, deps = {}) {
188
197
 
189
198
  return { path: target, size: manifest.size }
190
199
  } finally {
191
- // A failing disconnect must not swallow the real error already on its way up.
192
- try {
193
- await disconnect(client)
194
- } catch (err) {
195
- warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`)
196
- }
200
+ await closeQuietly(client, disconnect, (err) =>
201
+ warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`),
202
+ )
197
203
  }
198
204
  }
@@ -0,0 +1,15 @@
1
+ import { normalizeChatTarget } from '../client.js'
2
+ import { defaultConfigDir, loadConfig, saveConfig } from '../config.js'
3
+
4
+ export async function runSetDestination(options = {}, deps = {}) {
5
+ const { configDir = defaultConfigDir(), log = (line) => console.log(line) } = deps
6
+
7
+ // Normalize before touching the config: an unusable destination must not be written,
8
+ // or the next upload fails with an error about a value the user never really set.
9
+ const chat = normalizeChatTarget(options.to)
10
+ const config = await loadConfig(configDir)
11
+
12
+ await saveConfig({ ...config, defaultChat: String(chat) }, configDir)
13
+
14
+ log(`Destination set to ${chat}. It will be used for the next upload.`)
15
+ }
@@ -0,0 +1,93 @@
1
+ import path from 'node:path'
2
+
3
+ import { countChunks } from '../chunking.js'
4
+ import { assertLoggedIn, closeQuietly, connect as realConnect, describeChat } from '../client.js'
5
+ import { defaultConfigDir, loadConfig } from '../config.js'
6
+ import { runSetDestination } from './set-destination.js'
7
+ import { formatBytes } from '../progress.js'
8
+ import { listStates } from '../state.js'
9
+
10
+ const LABEL_WIDTH = 'Destination'.length + 2
11
+
12
+ function row(label, value) {
13
+ return `${label.padEnd(LABEL_WIDTH)}${value}`
14
+ }
15
+
16
+ function describeAccount(me) {
17
+ const name = [me.firstName, me.lastName].filter(Boolean).join(' ')
18
+ const handle = me.username ? ` (@${me.username})` : ''
19
+
20
+ return `${name || me.username || me.phone || 'unknown'}${handle}`
21
+ }
22
+
23
+ // The account line is the only part that needs Telegram, and it is also the only part that
24
+ // can fail. Whatever it costs, it must not take the rest of the report down with it: the
25
+ // unfinished backups are exactly what someone runs status to see after a session expires.
26
+ async function accountLine(config, options, deps) {
27
+ const { connect, disconnect } = deps
28
+
29
+ try {
30
+ assertLoggedIn(config)
31
+ } catch (err) {
32
+ return err.message
33
+ }
34
+
35
+ let client
36
+ try {
37
+ client = await connect(config, { verbose: options.verbose })
38
+ } catch (err) {
39
+ return err.message
40
+ }
41
+
42
+ try {
43
+ return describeAccount(await client.getMe())
44
+ } catch (err) {
45
+ return `could not be read: ${err.message}`
46
+ } finally {
47
+ await closeQuietly(client, disconnect)
48
+ }
49
+ }
50
+
51
+ export async function runStatus(options = {}, deps = {}) {
52
+ const {
53
+ configDir = defaultConfigDir(),
54
+ connect = realConnect,
55
+ disconnect = (client) => client.destroy(),
56
+ log = (line) => console.log(line),
57
+ } = deps
58
+
59
+ // `status --to @chan` reads as one request: point somewhere new, then show me where I am.
60
+ // Setting it first is also what makes the Destination line below tell the current truth.
61
+ if (options.to) {
62
+ await runSetDestination(options, { configDir, log: () => {} })
63
+ }
64
+
65
+ const config = await loadConfig(configDir)
66
+
67
+ log(row('Account', await accountLine(config, options, { connect, disconnect })))
68
+ log(
69
+ row(
70
+ 'Destination',
71
+ config.defaultChat
72
+ ? describeChat(config.defaultChat)
73
+ : 'none set — pass --to @my_backups to set one',
74
+ ),
75
+ )
76
+
77
+ const states = await listStates(configDir)
78
+
79
+ if (states.length === 0) {
80
+ log(row('Unfinished', 'none'))
81
+ return
82
+ }
83
+
84
+ log(row('Unfinished', `${states.length} backup${states.length === 1 ? '' : 's'}`))
85
+
86
+ for (const state of states) {
87
+ const total = countChunks(state.size, state.chunkSize)
88
+ const done = Object.keys(state.done ?? {}).length
89
+
90
+ log(` ${state.id} ${path.basename(state.path)} ${done}/${total} chunks ${formatBytes(state.size)}`)
91
+ log(` → ${describeChat(state.chat)}`)
92
+ }
93
+ }
@@ -12,7 +12,8 @@ import {
12
12
  parseSize,
13
13
  planChunks,
14
14
  } from '../chunking.js'
15
- import { connect as realConnect, requireChat } from '../client.js'
15
+ import { chunkCaption, manifestCaption } from '../caption.js'
16
+ import { closeQuietly, connect as realConnect, describeChat, requireChat } from '../client.js'
16
17
  import { defaultConfigDir, loadConfig, saveConfig } from '../config.js'
17
18
  import {
18
19
  buildManifest,
@@ -22,27 +23,41 @@ import {
22
23
  serializeManifest,
23
24
  } from '../manifest.js'
24
25
  import { createProgress, formatBytes, formatDuration } from '../progress.js'
25
- import { clearState, loadState, markChunkDone, saveState, stateDir, 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'
26
36
  import { uploadRange } from '../uploader.js'
27
37
 
28
38
  // Above this threshold the wait must be spelled out, per spec §8.
29
39
  const LONG_WAIT_MS = 60_000
30
40
 
31
- async function realSendChunk(client, peer, { inputFile, fileName, caption }) {
41
+ // Chunks and manifests differ only in where the bytes come from. Everything Telegram is
42
+ // told about them — document, not preview; this exact file name — is decided once.
43
+ async function sendDocument(client, peer, { file, fileName, caption }) {
32
44
  return await client.sendFile(peer, {
33
- file: inputFile,
45
+ file,
34
46
  caption,
35
47
  forceDocument: true,
36
48
  attributes: [new Api.DocumentAttributeFilename({ fileName })],
37
49
  })
38
50
  }
39
51
 
52
+ async function realSendChunk(client, peer, { inputFile, fileName, caption }) {
53
+ return await sendDocument(client, peer, { file: inputFile, fileName, caption })
54
+ }
55
+
40
56
  async function realSendManifest(client, peer, { bytes, fileName, caption }) {
41
- return await client.sendFile(peer, {
57
+ return await sendDocument(client, peer, {
42
58
  file: new CustomFile(fileName, bytes.length, '', bytes),
59
+ fileName,
43
60
  caption,
44
- forceDocument: true,
45
- attributes: [new Api.DocumentAttributeFilename({ fileName })],
46
61
  })
47
62
  }
48
63
 
@@ -57,6 +72,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
57
72
  retryOptions = {},
58
73
  writeErr = (line) => process.stderr.write(line),
59
74
  silent = false,
75
+ onBackupId = () => {},
60
76
  } = deps
61
77
 
62
78
  const absPath = path.resolve(filePath)
@@ -75,7 +91,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
75
91
 
76
92
  const config = await loadConfig(configDir)
77
93
  const chat = requireChat(options, config)
78
- const chunkSize = options['chunk-size'] ? parseSize(options['chunk-size']) : DEFAULT_CHUNK_SIZE
94
+ const requestedChunkSize = options['chunk-size'] ? parseSize(options['chunk-size']) : null
79
95
  const concurrency = options.concurrency ? Number(options.concurrency) : DEFAULT_CONCURRENCY
80
96
 
81
97
  if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) {
@@ -86,18 +102,35 @@ export async function runUpload(filePath, options = {}, deps = {}) {
86
102
  )
87
103
  }
88
104
 
89
- const chunks = planChunks(stat.size, chunkSize)
90
105
  const key = stateKey(absPath, stat.size, stat.mtimeMs)
91
106
 
92
107
  let state = await loadState(key, configDir)
93
- 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)
94
127
 
95
128
  if (resuming && state.chat !== String(chat)) {
96
- const stateFile = path.join(stateDir(configDir), `${key}.json`)
129
+ const file = stateFile(key, configDir)
97
130
  throw new Error(
98
131
  `This unfinished backup is going to ${state.chat}, but the current command targets ${chat} — ` +
99
132
  `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}.`,
133
+ `sending to ${state.chat}, or delete ${file} and run again to start a new backup in ${chat}.`,
101
134
  )
102
135
  }
103
136
 
@@ -116,8 +149,21 @@ export async function runUpload(filePath, options = {}, deps = {}) {
116
149
  done: {},
117
150
  }
118
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
+ }
119
163
  }
120
164
 
165
+ onBackupId(state.id)
166
+
121
167
  const log = silent ? () => {} : (line) => console.log(line)
122
168
  const warn = silent ? () => {} : writeErr
123
169
 
@@ -140,9 +186,9 @@ export async function runUpload(filePath, options = {}, deps = {}) {
140
186
 
141
187
  log(`Backup ${state.id}`)
142
188
  log(`File ${absPath} (${formatBytes(stat.size)}, ${chunks.length} chunks)`)
143
- log(`To ${chat}\n`)
189
+ log(`To ${describeChat(chat)}\n`)
144
190
 
145
- const client = await connect(config)
191
+ const client = await connect(config, { verbose: options.verbose })
146
192
 
147
193
  try {
148
194
  for (const chunk of chunks) {
@@ -154,13 +200,12 @@ export async function runUpload(filePath, options = {}, deps = {}) {
154
200
  const fileName = chunkFileName(state.id, chunk.i)
155
201
  const handle = await fs.open(absPath, 'r')
156
202
 
157
- const progress = silent
158
- ? { advance: () => {}, finish: () => {} }
159
- : createProgress({
160
- total: chunk.length,
161
- label: `Chunk ${chunk.i + 1}/${chunks.length}`,
162
- write: writeErr,
163
- })
203
+ // warn is already the no-op when silent, and createProgress draws through nothing else.
204
+ const progress = createProgress({
205
+ total: chunk.length,
206
+ label: `Chunk ${chunk.i + 1}/${chunks.length}`,
207
+ write: warn,
208
+ })
164
209
 
165
210
  try {
166
211
  const { inputFile, sha256 } = await uploadRange(client, handle.fd, {
@@ -178,7 +223,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
178
223
  const message = await sendChunk(client, chat, {
179
224
  inputFile,
180
225
  fileName,
181
- caption: `#dataark ${state.id} ${chunk.i + 1}/${chunks.length}`,
226
+ caption: chunkCaption({ id: state.id, number: chunk.i + 1, total: chunks.length }),
182
227
  })
183
228
 
184
229
  state = await markChunkDone(
@@ -218,7 +263,13 @@ export async function runUpload(filePath, options = {}, deps = {}) {
218
263
  await sendManifest(client, chat, {
219
264
  bytes: serializeManifest(manifest),
220
265
  fileName: manifestFileName(state.id),
221
- caption: `#dataark ${state.id} manifest`,
266
+ caption: manifestCaption({
267
+ id: manifest.id,
268
+ name: manifest.name,
269
+ size: manifest.size,
270
+ chunks: manifest.chunks.length,
271
+ createdAt: manifest.createdAt,
272
+ }),
222
273
  })
223
274
 
224
275
  await clearState(key, configDir)
@@ -227,11 +278,8 @@ export async function runUpload(filePath, options = {}, deps = {}) {
227
278
 
228
279
  return { id: state.id, chunks: chunks.length }
229
280
  } finally {
230
- // A failing disconnect must not swallow the real error already on its way up.
231
- try {
232
- await disconnect(client)
233
- } catch (err) {
234
- warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`)
235
- }
281
+ await closeQuietly(client, disconnect, (err) =>
282
+ warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`),
283
+ )
236
284
  }
237
285
  }
package/src/config.js CHANGED
@@ -26,16 +26,21 @@ export async function loadConfig(dir = defaultConfigDir()) {
26
26
  }
27
27
  }
28
28
 
29
- export async function saveConfig(config, dir = defaultConfigDir()) {
30
- await fs.mkdir(dir, { recursive: true, mode: 0o700 })
29
+ // Config and state are both small JSON files holding something that must survive a crash
30
+ // mid-write: write beside the target, then rename, which is atomic on the same filesystem.
31
+ export async function writeJsonAtomic(file, value) {
32
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
31
33
 
32
- const file = path.join(dir, FILE_NAME)
33
34
  const tmp = `${file}.tmp`
34
35
 
35
- await fs.writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
36
+ await fs.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
36
37
  await fs.rename(tmp, file)
37
38
  }
38
39
 
40
+ export async function saveConfig(config, dir = defaultConfigDir()) {
41
+ await writeJsonAtomic(path.join(dir, FILE_NAME), config)
42
+ }
43
+
39
44
  export async function clearSession(dir = defaultConfigDir()) {
40
45
  const config = await loadConfig(dir)
41
46
  delete config.session
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/manifest.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto'
2
2
 
3
+ import { countChunks } from './chunking.js'
4
+
3
5
  export const MANIFEST_VERSION = 1
4
6
 
5
7
  export function newBackupId(now = new Date(), randomHex = () => randomBytes(3).toString('hex')) {
@@ -61,7 +63,7 @@ export function parseManifest(input) {
61
63
  }
62
64
  })
63
65
 
64
- const expectedChunks = Math.ceil(manifest.size / manifest.chunkSize)
66
+ const expectedChunks = countChunks(manifest.size, manifest.chunkSize)
65
67
  if (manifest.chunks.length !== expectedChunks) {
66
68
  throw new Error(`Manifest is missing ${expectedChunks - manifest.chunks.length} chunk(s).`)
67
69
  }
package/src/progress.js CHANGED
@@ -52,9 +52,15 @@ export function createProgress({
52
52
  const startedAt = now()
53
53
  let done = 0
54
54
  let lastDrawnAt = startedAt
55
+ let widestLine = 0
55
56
 
57
+ // \r only moves the cursor home, it does not erase. A redraw that is shorter than the one
58
+ // before it (a shrinking ETA, a speed that changes unit) would leave the previous tail on
59
+ // screen, so pad every line out to the widest one drawn so far.
56
60
  function draw(suffix) {
57
- write(`\r${renderProgress({ done, total, elapsedMs: now() - startedAt, label })}${suffix}`)
61
+ const line = renderProgress({ done, total, elapsedMs: now() - startedAt, label })
62
+ widestLine = Math.max(widestLine, line.length)
63
+ write(`\r${line.padEnd(widestLine)}${suffix}`)
58
64
  }
59
65
 
60
66
  return {