telstore 0.1.7 → 0.1.9
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 +46 -6
- package/bin/telstore.js +28 -6
- package/package.json +1 -1
- package/src/cli.js +46 -4
- package/src/client.js +162 -9
- package/src/commands/delete.js +36 -3
- package/src/commands/list.js +200 -16
- package/src/commands/restore.js +179 -49
- package/src/commands/status.js +77 -10
- package/src/commands/verify.js +298 -0
- package/src/downloader.js +4 -1
- package/src/manifest.js +6 -1
- package/src/progress.js +1 -0
- package/src/state.js +161 -45
package/src/commands/list.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseManifestCaption } from '../caption.js'
|
|
2
2
|
import { chatName, describeChat } from '../chat.js'
|
|
3
3
|
import {
|
|
4
4
|
closeQuietly,
|
|
5
5
|
connect as realConnect,
|
|
6
|
-
|
|
6
|
+
iterDocuments,
|
|
7
|
+
iterManifestSearch,
|
|
7
8
|
} from '../client.js'
|
|
8
9
|
import { configFile, defaultConfigDir, loadConfig } from '../config.js'
|
|
10
|
+
import { MANIFEST_SUFFIX } from '../manifest.js'
|
|
9
11
|
import { assertLoggedIn } from '../session.js'
|
|
10
12
|
import { requireChat, resolveSettings } from '../settings.js'
|
|
11
13
|
|
|
@@ -30,15 +32,48 @@ function shorten(note) {
|
|
|
30
32
|
return note.length > NOTE_WIDTH ? `${note.slice(0, NOTE_WIDTH - 1)}…` : note
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
// What list has to read through depends on how big the backups are, not how many there are:
|
|
36
|
+
// it walks from the newest message down and stops the moment it has --limit manifests, so the
|
|
37
|
+
// three thousandth backup in a chat costs nothing because it is never reached. What costs is
|
|
38
|
+
// the chunks in between — one message each — which is why the ceiling is a budget per backup
|
|
39
|
+
// asked for rather than one number for every chat.
|
|
40
|
+
//
|
|
41
|
+
// 60 documents per backup covers a backup of about 105GB at the default chunk size. A fixed
|
|
42
|
+
// 1000 was both too tight and too loose at once: twenty backups of 100GB need 1140 documents
|
|
43
|
+
// and got 1000 of them, while `--limit 5` never needed more than 300.
|
|
44
|
+
export const DOCUMENTS_PER_BACKUP = 60
|
|
45
|
+
|
|
46
|
+
// A search result is already a manifest, so it buys far more backups per document read. Its
|
|
47
|
+
// budget only has to cover what matchesTerm throws away — measured 2026-09-08, a term like
|
|
48
|
+
// "2026-09" comes back matching everything and is then cut down to the month asked for.
|
|
49
|
+
export const RESULTS_PER_BACKUP = 20
|
|
50
|
+
|
|
51
|
+
// --limit takes any whole number, so the budget needs an end of its own: without one,
|
|
52
|
+
// `--limit 100000` would ask for six million documents and sixty thousand requests.
|
|
53
|
+
export const MAX_LIST_DOCUMENTS = 10000
|
|
54
|
+
|
|
55
|
+
export function documentBudget(limit, perBackup) {
|
|
56
|
+
return Math.min(limit * perBackup, MAX_LIST_DOCUMENTS)
|
|
38
57
|
}
|
|
39
58
|
|
|
59
|
+
// Stopping at the ceiling used to leave the reader standing there: "there may be older backups
|
|
60
|
+
// further back" is true and offers nothing to do about it. --search reaches them without
|
|
61
|
+
// reading the chunks in between, which is the whole reason it exists.
|
|
62
|
+
const DEEPER_HINT =
|
|
63
|
+
'"npx telstore list --search <text>" reaches older ones without reading every chunk ' +
|
|
64
|
+
'in between.'
|
|
65
|
+
|
|
66
|
+
// Telegram matches whole words and nothing shorter: measured 2026-09-08, "projex" found
|
|
67
|
+
// projex.zip while "proj", "pro" and "pr" each found nothing at all. That is the one way a
|
|
68
|
+
// search can come back empty over a backup that is plainly there, so the empty answer says
|
|
69
|
+
// it, and points at the listing that never asks the index.
|
|
70
|
+
const SEARCH_MISS_HELP =
|
|
71
|
+
'Telegram matches whole words: "projex" finds projex.zip, "proj" does not. ' +
|
|
72
|
+
'Run "npx telstore list" without --search to see every backup without going through ' +
|
|
73
|
+
'the search index.'
|
|
74
|
+
|
|
40
75
|
function backupIdFromFileName(fileName) {
|
|
41
|
-
return fileName.
|
|
76
|
+
return fileName.slice(0, -MANIFEST_SUFFIX.length)
|
|
42
77
|
}
|
|
43
78
|
|
|
44
79
|
function utcDay(unixSeconds) {
|
|
@@ -74,6 +109,76 @@ function toRow(message) {
|
|
|
74
109
|
}
|
|
75
110
|
}
|
|
76
111
|
|
|
112
|
+
// A search term is a question about one run, and an empty one is not a question: answering
|
|
113
|
+
// it with every backup would look exactly like a search that matched everything.
|
|
114
|
+
function parseSearchTerm(raw) {
|
|
115
|
+
if (raw === undefined || raw === null) return null
|
|
116
|
+
|
|
117
|
+
const term = String(raw).trim()
|
|
118
|
+
|
|
119
|
+
if (term === '') {
|
|
120
|
+
throw new Error(
|
|
121
|
+
'--search is empty. Write the word to look for, or leave the flag off — "list" ' +
|
|
122
|
+
'without it shows every backup.',
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return term
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The four fields a person remembers about a backup, and the whole of what --search compares
|
|
130
|
+
// against. The note is matched entire rather than the 40 characters the table has room for:
|
|
131
|
+
// a word that fell off the end of the column is still a word they typed. A card that cannot
|
|
132
|
+
// be read back leaves only what the message itself knows.
|
|
133
|
+
function searchableFields(message) {
|
|
134
|
+
const id = backupIdFromFileName(message.fileName)
|
|
135
|
+
const card = parseManifestCaption(message.caption)
|
|
136
|
+
|
|
137
|
+
if (!card) return [id, utcDay(message.date)]
|
|
138
|
+
|
|
139
|
+
return [id, card.name, card.note ?? '', card.createdAt.slice(0, 10)]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Telegram decides what comes back; this decides what is true. The index answers a term the
|
|
143
|
+
// way it wants to — measured 2026-09-08, "2026-09" returned every document in the chat — so
|
|
144
|
+
// a hit is shown only if the term really is in one of the fields above. Without this pass a
|
|
145
|
+
// search for a month would list backups from every other month, which is the plausible wrong
|
|
146
|
+
// answer this project exists to refuse.
|
|
147
|
+
function matchesTerm(message, term) {
|
|
148
|
+
return searchableFields(message).some((field) => field.toLowerCase().includes(term))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// A walk of one page is over in about the time it takes to notice — 165ms against a real
|
|
152
|
+
// chat — and that is the usual case, so nothing is drawn for the first stretch: a line that
|
|
153
|
+
// appears and is wiped in the same breath is a flicker, not information. Past that the read
|
|
154
|
+
// is long enough that silence reads as the hang this project refuses everywhere else.
|
|
155
|
+
//
|
|
156
|
+
// \r only moves the cursor home, so every line is padded to the widest one drawn and the last
|
|
157
|
+
// write wipes the row: the table that follows must never land on half a progress line.
|
|
158
|
+
const NOTICE_QUIET_MS = 400
|
|
159
|
+
const NOTICE_INTERVAL_MS = 200
|
|
160
|
+
|
|
161
|
+
function createWalkNotice({ write, now, quietMs = NOTICE_QUIET_MS, intervalMs = NOTICE_INTERVAL_MS }) {
|
|
162
|
+
const startedAt = now()
|
|
163
|
+
let lastDrawnAt = 0
|
|
164
|
+
let widest = 0
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
tick(text) {
|
|
168
|
+
if (now() - startedAt < quietMs) return
|
|
169
|
+
if (lastDrawnAt !== 0 && now() - lastDrawnAt < intervalMs) return
|
|
170
|
+
|
|
171
|
+
lastDrawnAt = now()
|
|
172
|
+
widest = Math.max(widest, text.length)
|
|
173
|
+
write(`\r${text.padEnd(widest)}`)
|
|
174
|
+
},
|
|
175
|
+
clear() {
|
|
176
|
+
if (widest === 0) return
|
|
177
|
+
write(`\r${' '.repeat(widest)}\r`)
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
77
182
|
function renderTable(rows) {
|
|
78
183
|
// Most people never write a note, and a column of dashes tells them nothing they did not
|
|
79
184
|
// already know while costing every other column the width it takes.
|
|
@@ -102,12 +207,21 @@ export async function runList(options = {}, deps = {}) {
|
|
|
102
207
|
configDir = defaultConfigDir(),
|
|
103
208
|
connect = realConnect,
|
|
104
209
|
disconnect = (client) => client.destroy(),
|
|
105
|
-
|
|
210
|
+
readDocuments = iterDocuments,
|
|
211
|
+
searchManifests = iterManifestSearch,
|
|
106
212
|
log = (line) => console.log(line),
|
|
213
|
+
// The notice is drawn on stderr, and only onto a terminal: unlike an upload's progress
|
|
214
|
+
// bar, `list` is a command people pipe into grep, and a carriage return in a log file is
|
|
215
|
+
// rubbish. Null means draw nothing at all.
|
|
216
|
+
writeProgress = process.stderr.isTTY ? (text) => process.stderr.write(text) : null,
|
|
217
|
+
now = () => Date.now(),
|
|
107
218
|
} = deps
|
|
108
219
|
|
|
109
220
|
const config = await loadConfig(configDir)
|
|
110
221
|
const { values: settings } = resolveSettings(options, config, { file: configFile(configDir) })
|
|
222
|
+
// Before the login gate: a bad term is the user's own typing, and telling them to log in
|
|
223
|
+
// first would send them off after the wrong thing.
|
|
224
|
+
const term = parseSearchTerm(options.search)
|
|
111
225
|
// Ask about the login before the destination: telling someone who has never logged in
|
|
112
226
|
// to pick a chat sends them off after the wrong thing.
|
|
113
227
|
assertLoggedIn(config)
|
|
@@ -115,29 +229,99 @@ export async function runList(options = {}, deps = {}) {
|
|
|
115
229
|
|
|
116
230
|
const client = await connect(config, { verbose: settings.verbose })
|
|
117
231
|
|
|
118
|
-
|
|
232
|
+
// Walked rather than searched, unless a term was given. Telegram's text index can answer
|
|
233
|
+
// nothing at all about a chat that is full of backups — it did for a whole day in a channel
|
|
234
|
+
// that had just been created — and "No backups found" is a sentence someone acts on. The
|
|
235
|
+
// documents themselves were right every time they were asked for.
|
|
236
|
+
//
|
|
237
|
+
// --search is the one place worth paying the index for: a term matches a backup that may be
|
|
238
|
+
// ten thousand messages back, and walking to it would cost a request per hundred documents
|
|
239
|
+
// in between, every time, for as long as the chat keeps growing. So the search narrows and
|
|
240
|
+
// matchesTerm decides — the index is asked where to look, never what is true.
|
|
241
|
+
const found = []
|
|
242
|
+
let read = 0
|
|
243
|
+
|
|
244
|
+
const unit = term ? 'search results' : 'documents'
|
|
245
|
+
const budget = documentBudget(settings.limit, term ? RESULTS_PER_BACKUP : DOCUMENTS_PER_BACKUP)
|
|
246
|
+
|
|
247
|
+
const results = term
|
|
248
|
+
? searchManifests(client, chat, term, { max: budget })
|
|
249
|
+
: readDocuments(client, chat, { max: budget })
|
|
250
|
+
|
|
251
|
+
const wanted = term === null ? null : term.toLowerCase()
|
|
252
|
+
const notice = writeProgress ? createWalkNotice({ write: writeProgress, now }) : null
|
|
253
|
+
|
|
119
254
|
try {
|
|
120
|
-
|
|
255
|
+
for await (const document of results) {
|
|
256
|
+
read += 1
|
|
257
|
+
|
|
258
|
+
notice?.tick(
|
|
259
|
+
`Reading ${chatName(chat)}… ${read} ${unit}, ${found.length} backup` +
|
|
260
|
+
`${found.length === 1 ? '' : 's'}`,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
if (!document.fileName?.endsWith(MANIFEST_SUFFIX)) continue
|
|
264
|
+
if (wanted !== null && !matchesTerm(document, wanted)) continue
|
|
265
|
+
|
|
266
|
+
found.push(document)
|
|
267
|
+
|
|
268
|
+
// Everything past here is older than the twentieth newest backup, and nobody asked
|
|
269
|
+
// for it. In a chat of ten thousand chunks this is the difference between one
|
|
270
|
+
// request and ten.
|
|
271
|
+
if (found.length >= settings.limit) break
|
|
272
|
+
}
|
|
121
273
|
} finally {
|
|
274
|
+
notice?.clear()
|
|
122
275
|
await closeQuietly(client, disconnect)
|
|
123
276
|
}
|
|
124
277
|
|
|
278
|
+
// The one thing either reader cannot see is what lies past its own ceiling, so anything it
|
|
279
|
+
// says about the whole chat has to stop at the edge of what it read.
|
|
280
|
+
const capped = found.length < settings.limit && read >= budget
|
|
281
|
+
|
|
125
282
|
log(`Destination ${describeChat(chat)}`)
|
|
283
|
+
if (term) log(`Search ${JSON.stringify(term)}`)
|
|
126
284
|
log('')
|
|
127
285
|
|
|
128
|
-
const rows = found
|
|
129
|
-
.filter((message) => message.fileName?.endsWith('.manifest.json'))
|
|
130
|
-
.map(toRow)
|
|
286
|
+
const rows = found.map(toRow)
|
|
131
287
|
|
|
132
288
|
if (rows.length === 0) {
|
|
133
|
-
|
|
289
|
+
if (term) {
|
|
290
|
+
log(
|
|
291
|
+
capped
|
|
292
|
+
? `No backups matching ${JSON.stringify(term)} in the newest ${budget} ` +
|
|
293
|
+
`${unit} from ${chatName(chat)}. There may be older ones further back.`
|
|
294
|
+
: `No backups matching ${JSON.stringify(term)} in ${chatName(chat)}.`,
|
|
295
|
+
)
|
|
296
|
+
log(SEARCH_MISS_HELP)
|
|
297
|
+
return rows
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
log(
|
|
301
|
+
capped
|
|
302
|
+
? `No backups in the newest ${budget} ${unit} of ${chatName(chat)}. ` +
|
|
303
|
+
`There may be older ones further back. ${DEEPER_HINT}`
|
|
304
|
+
: `No backups found in ${chatName(chat)}. Upload one with: npx telstore <file>`,
|
|
305
|
+
)
|
|
134
306
|
return rows
|
|
135
307
|
}
|
|
136
308
|
|
|
137
309
|
for (const line of renderTable(rows)) log(line)
|
|
138
310
|
|
|
139
311
|
log('')
|
|
140
|
-
log(
|
|
312
|
+
log(
|
|
313
|
+
`${rows.length} backup${rows.length === 1 ? '' : 's'}` +
|
|
314
|
+
`${term ? ` matching ${JSON.stringify(term)}` : ''}. ` +
|
|
315
|
+
'Restore with: npx telstore restore <backup-id>',
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
if (capped) {
|
|
319
|
+
log(
|
|
320
|
+
`Read the newest ${budget} ${unit} in ${chatName(chat)} to find them — ` +
|
|
321
|
+
`there may be older ${term ? 'matches' : 'backups'} further back.` +
|
|
322
|
+
`${term ? '' : ` ${DEEPER_HINT}`}`,
|
|
323
|
+
)
|
|
324
|
+
}
|
|
141
325
|
|
|
142
326
|
return rows
|
|
143
327
|
}
|
package/src/commands/restore.js
CHANGED
|
@@ -11,9 +11,10 @@ import { askConfirm } from '../confirm.js'
|
|
|
11
11
|
import { configFile, defaultConfigDir, loadConfig } from '../config.js'
|
|
12
12
|
import { assertLoggedIn } from '../session.js'
|
|
13
13
|
import { requireChat, resolveSettings } from '../settings.js'
|
|
14
|
-
import { downloadToFile } from '../downloader.js'
|
|
14
|
+
import { downloadToFile, hashRange } from '../downloader.js'
|
|
15
15
|
import { parseManifest } from '../manifest.js'
|
|
16
16
|
import { createProgress, formatBytes, formatDuration } from '../progress.js'
|
|
17
|
+
import { clearRestore, pruneRestores, restoreKey, saveRestore } from '../state.js'
|
|
17
18
|
|
|
18
19
|
// Anything past a minute of waiting needs saying out loud; below that the pause is shorter
|
|
19
20
|
// than the time a user would spend wondering about it.
|
|
@@ -50,6 +51,29 @@ function safeOutName(name) {
|
|
|
50
51
|
return base
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
// How many chunks at the front of a .partial already hold what the manifest says they
|
|
55
|
+
// should. The evidence is the file, never a record: a record makes claims about a local
|
|
56
|
+
// file anyone can edit between runs, and a claim that is wrong here renames a corrupt file
|
|
57
|
+
// into place. Every chunk in the finished file was hashed against the manifest by the run
|
|
58
|
+
// that renamed it, whether this run downloaded it or found it already there.
|
|
59
|
+
async function scanPartial(handle, manifest, log) {
|
|
60
|
+
let done = 0
|
|
61
|
+
|
|
62
|
+
for (const chunk of manifest.chunks) {
|
|
63
|
+
const digest = await hashRange(handle.fd, chunk.i * manifest.chunkSize, chunk.size)
|
|
64
|
+
|
|
65
|
+
// Downloads run in order, so what is already present is a prefix. The first chunk that
|
|
66
|
+
// does not match is where this run starts, and reading past it would hash gigabytes
|
|
67
|
+
// nobody has written yet.
|
|
68
|
+
if (digest !== chunk.sha256) break
|
|
69
|
+
|
|
70
|
+
done += 1
|
|
71
|
+
log(`Chunk ${chunk.i + 1}/${manifest.chunks.length} already restored, skipping.`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return done
|
|
75
|
+
}
|
|
76
|
+
|
|
53
77
|
export async function runRestore(backupId, options = {}, deps = {}) {
|
|
54
78
|
const {
|
|
55
79
|
connect = realConnect,
|
|
@@ -64,6 +88,7 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
64
88
|
writeErr = (line) => process.stderr.write(line),
|
|
65
89
|
log: writeLog = (line) => console.log(line),
|
|
66
90
|
silent = false,
|
|
91
|
+
onBackupId = () => {},
|
|
67
92
|
} = deps
|
|
68
93
|
|
|
69
94
|
const config = await loadConfig(configDir)
|
|
@@ -112,6 +137,41 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
112
137
|
const target = path.resolve(options.out ?? safeOutName(manifest.name))
|
|
113
138
|
const partial = `${target}.partial`
|
|
114
139
|
|
|
140
|
+
const key = restoreKey(backupId, target)
|
|
141
|
+
|
|
142
|
+
// The record is a signpost for `status`, never evidence. The scan above proved every
|
|
143
|
+
// chunk it skipped against the manifest and would do so again if this file vanished, so
|
|
144
|
+
// a signpost that cannot be planted warns and gets out of the way. Deliberately the
|
|
145
|
+
// opposite of markChunkDone, where a failed write must be fatal because losing it
|
|
146
|
+
// strands chunks in a chat with nothing left pointing at them.
|
|
147
|
+
// A record that cannot be written warns once, not once per chunk: warn is the same
|
|
148
|
+
// stderr stream the progress bar owns with \r, and a warning for every one of ~30
|
|
149
|
+
// chunks on a real restore would tear through the bar as badly as the retry
|
|
150
|
+
// announcements above reason about at length.
|
|
151
|
+
let recordWarned = false
|
|
152
|
+
|
|
153
|
+
async function note(done) {
|
|
154
|
+
try {
|
|
155
|
+
await saveRestore(
|
|
156
|
+
key,
|
|
157
|
+
{
|
|
158
|
+
v: 1,
|
|
159
|
+
id: backupId,
|
|
160
|
+
target,
|
|
161
|
+
chat: String(chat),
|
|
162
|
+
size: manifest.size,
|
|
163
|
+
chunks: manifest.chunks.length,
|
|
164
|
+
done,
|
|
165
|
+
},
|
|
166
|
+
configDir,
|
|
167
|
+
)
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (recordWarned) return
|
|
170
|
+
recordWarned = true
|
|
171
|
+
warn(`\nWarning: could not record restore progress: ${err.message}\n`)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
115
175
|
// Only ENOENT means "no file yet". Treating a permission or I/O error as absence
|
|
116
176
|
// would have telstore overwrite the user's file without asking.
|
|
117
177
|
let exists = true
|
|
@@ -129,66 +189,120 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
129
189
|
log(`Backup ${manifest.id}`)
|
|
130
190
|
log(`File ${target} (${formatBytes(manifest.size)}, ${manifest.chunks.length} chunks)\n`)
|
|
131
191
|
|
|
132
|
-
|
|
192
|
+
let handle
|
|
193
|
+
let resuming = true
|
|
133
194
|
|
|
195
|
+
// r+ keeps whatever an earlier run left behind; w+ truncates it to zero, which is what
|
|
196
|
+
// made a kept .partial useless. Only ENOENT means "no file yet" — a permission error
|
|
197
|
+
// quietly becoming "start over" is how two hours of downloading disappear unexplained.
|
|
134
198
|
try {
|
|
199
|
+
handle = await fs.open(partial, 'r+')
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if (err.code !== 'ENOENT') throw err
|
|
202
|
+
handle = await fs.open(partial, 'w+')
|
|
203
|
+
resuming = false
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// The message this id feeds says a .partial was kept, so naming the id is only honest
|
|
207
|
+
// once one actually exists — before this line, Ctrl-C would have nothing to carry on from.
|
|
208
|
+
onBackupId(backupId)
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
// Extends a short .partial with zeros and cuts an over-long one, and touches no byte
|
|
212
|
+
// below manifest.size — so one path serves a fresh file and a resumed one alike.
|
|
135
213
|
await handle.truncate(manifest.size)
|
|
136
214
|
|
|
137
|
-
|
|
138
|
-
// byte counts, the speed and the ETA all describe the file, so the line runs 0% to 100%
|
|
139
|
-
// once instead of restarting at every chunk boundary — with 1800MB chunks, a per-chunk
|
|
140
|
-
// ETA answers a question nobody asked.
|
|
141
|
-
// warn is already the no-op when silent, and createProgress draws through nothing else.
|
|
142
|
-
const progress = createProgress({
|
|
143
|
-
total: manifest.size,
|
|
144
|
-
label: `Chunk 1/${manifest.chunks.length}`,
|
|
145
|
-
write: warn,
|
|
146
|
-
})
|
|
215
|
+
let done = 0
|
|
147
216
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
217
|
+
if (resuming) {
|
|
218
|
+
// Hashing 1800MB takes about 9 seconds, so a large scan runs for minutes. Silence
|
|
219
|
+
// that long is the hang this project refuses everywhere: the heading lands before
|
|
220
|
+
// the first read and a line per chunk arrives as the scan advances.
|
|
221
|
+
log(`Checking what is already in ${partial}...`)
|
|
222
|
+
done = await scanPartial(handle, manifest, log)
|
|
223
|
+
if (done === 0) log(`Nothing in ${partial} matches this backup, starting over.`)
|
|
224
|
+
log('')
|
|
225
|
+
}
|
|
153
226
|
|
|
154
|
-
|
|
227
|
+
await note(done)
|
|
155
228
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
229
|
+
// Housekeeping, the way runUpload prunes its own records. It removes signposts only:
|
|
230
|
+
// dropping one costs a line of `status` for a .partial that still resumes.
|
|
231
|
+
try {
|
|
232
|
+
await pruneRestores(configDir)
|
|
233
|
+
} catch (err) {
|
|
234
|
+
warn(`\nWarning: could not tidy old restore records: ${err.message}\n`)
|
|
235
|
+
}
|
|
162
236
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
237
|
+
const pending = manifest.chunks.slice(done)
|
|
238
|
+
|
|
239
|
+
// A .partial holding every chunk is what a run that died between its last chunk and
|
|
240
|
+
// the rename leaves: no bar at all, rather than one springing into existence at 100%.
|
|
241
|
+
if (pending.length > 0) {
|
|
242
|
+
const present = manifest.chunks
|
|
243
|
+
.slice(0, done)
|
|
244
|
+
.reduce((sum, chunk) => sum + chunk.size, 0)
|
|
245
|
+
|
|
246
|
+
// One bar for the whole restore. The label names the chunk in flight, but the bar, the
|
|
247
|
+
// byte counts, the speed and the ETA all describe the file, so the line runs 0% to 100%
|
|
248
|
+
// once instead of restarting at every chunk boundary — with 1800MB chunks, a per-chunk
|
|
249
|
+
// ETA answers a question nobody asked. Chunks an earlier run left count towards the bar
|
|
250
|
+
// but not towards the speed, so an hour-old chunk cannot inflate the ETA of the rest.
|
|
251
|
+
// warn is already the no-op when silent, and createProgress draws through nothing else.
|
|
252
|
+
const progress = createProgress({
|
|
253
|
+
total: manifest.size,
|
|
254
|
+
done: present,
|
|
255
|
+
label: `Chunk ${pending[0].i + 1}/${manifest.chunks.length}`,
|
|
256
|
+
write: warn,
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
for (const chunk of pending) {
|
|
261
|
+
// Before getMessage, not after: the bar is then on screen from the first moment,
|
|
262
|
+
// and finish() below always has a line to close.
|
|
263
|
+
progress.setLabel(`Chunk ${chunk.i + 1}/${manifest.chunks.length}`)
|
|
264
|
+
|
|
265
|
+
const message = await getMessage(client, chat, chunk.msgId)
|
|
266
|
+
|
|
267
|
+
if (!message) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
`Missing chunk ${chunk.i + 1}/${manifest.chunks.length}: message ${chunk.msgId} is no longer in ${chat}. ` +
|
|
270
|
+
'This backup cannot be restored.',
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const { sha256, size } = await downloadChunk(
|
|
275
|
+
client,
|
|
276
|
+
message,
|
|
277
|
+
handle,
|
|
278
|
+
chunk.i * manifest.chunkSize,
|
|
279
|
+
progress.advance,
|
|
280
|
+
{
|
|
281
|
+
retryOptions: { ...retryOptions, onRetry },
|
|
282
|
+
concurrency: settings.downloadConcurrency,
|
|
283
|
+
},
|
|
178
284
|
)
|
|
179
|
-
}
|
|
180
285
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
286
|
+
if (size !== chunk.size) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`Chunk ${chunk.i + 1} has ${size} bytes, the manifest records ${chunk.size} bytes — mismatch.`,
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (sha256 !== chunk.sha256) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`Chunk ${chunk.i + 1} has a sha256 that does not match the manifest. The download is kept at ${partial} for inspection.`,
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
await note(chunk.i + 1)
|
|
185
299
|
}
|
|
300
|
+
} finally {
|
|
301
|
+
// The bar owns a line that \r keeps returning to. Ending it here rather than after the
|
|
302
|
+
// loop means a chunk that fails mid-download still leaves the cursor on a fresh line,
|
|
303
|
+
// so "Error: ..." does not land on top of the bar.
|
|
304
|
+
progress.finish()
|
|
186
305
|
}
|
|
187
|
-
} finally {
|
|
188
|
-
// The bar owns a line that \r keeps returning to. Ending it here rather than after the
|
|
189
|
-
// loop means a chunk that fails mid-download still leaves the cursor on a fresh line,
|
|
190
|
-
// so "Error: ..." does not land on top of the bar.
|
|
191
|
-
progress.finish()
|
|
192
306
|
}
|
|
193
307
|
} finally {
|
|
194
308
|
await handle.close()
|
|
@@ -208,6 +322,13 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
208
322
|
|
|
209
323
|
await fs.rename(partial, target)
|
|
210
324
|
|
|
325
|
+
// The restore is finished; the signpost has nothing left to point at.
|
|
326
|
+
try {
|
|
327
|
+
await clearRestore(key, configDir)
|
|
328
|
+
} catch (err) {
|
|
329
|
+
warn(`\nWarning: could not remove the restore record: ${err.message}\n`)
|
|
330
|
+
}
|
|
331
|
+
|
|
211
332
|
log(`\nDone. Wrote ${formatBytes(manifest.size)} to ${target}`)
|
|
212
333
|
|
|
213
334
|
return { path: target, size: manifest.size }
|
|
@@ -231,6 +352,8 @@ export async function runRestores(backupIds, options = {}, deps = {}) {
|
|
|
231
352
|
writeErr = (line) => process.stderr.write(line),
|
|
232
353
|
log: writeLog = (line) => console.log(line),
|
|
233
354
|
silent = false,
|
|
355
|
+
onRestoreDone = () => {},
|
|
356
|
+
onBackupId = () => {},
|
|
234
357
|
} = deps
|
|
235
358
|
|
|
236
359
|
// One id must read exactly as it did before this existed: --out still works, the error still
|
|
@@ -289,12 +412,19 @@ export async function runRestores(backupIds, options = {}, deps = {}) {
|
|
|
289
412
|
try {
|
|
290
413
|
const { path: target, size } = await runRestore(backupId, options, perId)
|
|
291
414
|
results.push({ id: backupId, path: target, size })
|
|
415
|
+
onRestoreDone({ id: backupId, path: target })
|
|
292
416
|
} catch (err) {
|
|
293
417
|
// A backup whose chunks are gone says nothing about the next one, and the summary at
|
|
294
418
|
// the end would arrive an hour after the bar of the following id started scrolling
|
|
295
419
|
// over it — so it is named here, and again down there, and carried out as exit code 1.
|
|
296
420
|
results.push({ id: backupId, error: err.message })
|
|
297
421
|
warn(`\n${backupId} failed: ${err.message}\n`)
|
|
422
|
+
} finally {
|
|
423
|
+
// Cleared whether this id finished, failed, or never got as far as opening a
|
|
424
|
+
// .partial: otherwise a Ctrl-C while the next id is still connecting, searching for
|
|
425
|
+
// its manifest, or blocked at the overwrite prompt would go on naming this one, as
|
|
426
|
+
// though there were a file to carry on from when there is none yet.
|
|
427
|
+
onBackupId(null)
|
|
298
428
|
}
|
|
299
429
|
}
|
|
300
430
|
} finally {
|