telstore 0.1.7 → 0.1.8
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 +9 -2
- package/bin/telstore.js +13 -6
- package/package.json +1 -1
- package/src/cli.js +32 -2
- package/src/commands/delete.js +34 -1
- package/src/commands/restore.js +179 -49
- package/src/commands/status.js +77 -10
- package/src/downloader.js +4 -1
- package/src/progress.js +1 -0
- package/src/state.js +161 -45
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ npx telstore restore telstore-20260905-7f3a91
|
|
|
29
29
|
| `telstore list` | The backups stored in the destination, newest first. |
|
|
30
30
|
| `telstore restore <backup-id>...` | Download every chunk and reassemble the file. Several ids run one after another. |
|
|
31
31
|
| `telstore delete <backup-id>...` | Remove a backup's chunks and manifest from the chat, for good. Several ids are listed and confirmed once. |
|
|
32
|
-
| `telstore status` | Account, destination, and unfinished
|
|
32
|
+
| `telstore status` | Account, destination, and unfinished uploads and restores. |
|
|
33
33
|
| `telstore config` | Show or change settings. |
|
|
34
34
|
| `telstore token` | Print a session token for a machine you do not trust. |
|
|
35
35
|
| `telstore logout` | Remove the locally stored session. |
|
|
@@ -143,7 +143,14 @@ After a batch, run telstore again with **only the files that are left**: the fin
|
|
|
143
143
|
have had their records cleared, so repeating the whole command would upload them a second
|
|
144
144
|
time as new backups. `npx telstore status` lists what is unfinished.
|
|
145
145
|
|
|
146
|
-
|
|
146
|
+
## Resuming a restore
|
|
147
|
+
|
|
148
|
+
`Ctrl-C` mid-restore keeps the `<target>.partial` file rather than throwing it away. Running
|
|
149
|
+
the same command again hashes each chunk-sized region of it against the manifest, in order,
|
|
150
|
+
and carries on from the first one that does not match — nothing already on disk is trusted
|
|
151
|
+
just because it is there. `npx telstore status` lists unfinished restores alongside
|
|
152
|
+
unfinished uploads, with a resume command for each.
|
|
153
|
+
|
|
147
154
|
And `delete` has **no undo**: Telegram is the only copy.
|
|
148
155
|
|
|
149
156
|
## Running on a machine you do not trust
|
package/bin/telstore.js
CHANGED
|
@@ -14,9 +14,9 @@ const SIGINT_EXIT_CODE = 130
|
|
|
14
14
|
let currentCommand = null
|
|
15
15
|
let currentBackupId = null
|
|
16
16
|
|
|
17
|
-
// A batch clears each finished
|
|
18
|
-
// are
|
|
19
|
-
const
|
|
17
|
+
// A batch clears each finished item's record as it goes, so by the time Ctrl-C lands these
|
|
18
|
+
// are transfers no second run should touch. Ctrl-C needs their names to say so.
|
|
19
|
+
const finished = []
|
|
20
20
|
|
|
21
21
|
process.on('SIGINT', () => {
|
|
22
22
|
// A passphrase prompt has stdin in raw mode, and process.exit skips readline's own cleanup.
|
|
@@ -24,7 +24,7 @@ process.on('SIGINT', () => {
|
|
|
24
24
|
if (process.stdin.isTTY) process.stdin.setRawMode(false)
|
|
25
25
|
|
|
26
26
|
process.stderr.write(
|
|
27
|
-
interruptMessage(currentCommand, { backupId: currentBackupId, done:
|
|
27
|
+
interruptMessage(currentCommand, { backupId: currentBackupId, done: finished }),
|
|
28
28
|
)
|
|
29
29
|
process.exit(SIGINT_EXIT_CODE)
|
|
30
30
|
})
|
|
@@ -104,7 +104,7 @@ async function main() {
|
|
|
104
104
|
currentBackupId = id
|
|
105
105
|
},
|
|
106
106
|
onFileDone: (file) => {
|
|
107
|
-
if (file.id)
|
|
107
|
+
if (file.id) finished.push(file)
|
|
108
108
|
},
|
|
109
109
|
})
|
|
110
110
|
|
|
@@ -121,7 +121,14 @@ async function main() {
|
|
|
121
121
|
|
|
122
122
|
const { runRestores } = await import('../src/commands/restore.js')
|
|
123
123
|
|
|
124
|
-
const { failed } = await runRestores(parsed.args, parsed.options
|
|
124
|
+
const { failed } = await runRestores(parsed.args, parsed.options, {
|
|
125
|
+
onBackupId: (id) => {
|
|
126
|
+
currentBackupId = id
|
|
127
|
+
},
|
|
128
|
+
onRestoreDone: (item) => {
|
|
129
|
+
finished.push(item)
|
|
130
|
+
},
|
|
131
|
+
})
|
|
125
132
|
|
|
126
133
|
if (failed > 0) process.exitCode = 1
|
|
127
134
|
return
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -36,7 +36,7 @@ Usage:
|
|
|
36
36
|
npx telstore list List the backups stored in the destination
|
|
37
37
|
npx telstore restore <backup-id>... Download the chunks and reassemble the files
|
|
38
38
|
npx telstore delete <backup-id>... Remove backups' chunks and manifests from the chat
|
|
39
|
-
npx telstore status Show the account, the destination and unfinished
|
|
39
|
+
npx telstore status Show the account, the destination and unfinished uploads and restores
|
|
40
40
|
npx telstore config Show every setting and where its value comes from
|
|
41
41
|
npx telstore logout Remove the saved session
|
|
42
42
|
|
|
@@ -120,8 +120,38 @@ export function interruptMessage(command, { backupId, done = [] } = {}) {
|
|
|
120
120
|
)
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
// A restore keeps its .partial now, and the next run proves each chunk in it against the
|
|
124
|
+
// manifest before trusting a byte — so "running again starts over", which this said while
|
|
125
|
+
// there was nothing to resume from, would now be false.
|
|
123
126
|
if (command === 'restore') {
|
|
124
|
-
|
|
127
|
+
// Finished ids have been renamed to their real names and their records removed, so
|
|
128
|
+
// repeating the whole command line would meet an overwrite prompt and then download
|
|
129
|
+
// them again from nothing. Name them and ask for the rest, exactly as a batch upload does.
|
|
130
|
+
if (done.length > 0) {
|
|
131
|
+
const width = Math.max(...done.map((item) => basename(item.path).length))
|
|
132
|
+
const finished = done
|
|
133
|
+
.map((item) => ` ${basename(item.path).padEnd(width)} ${item.id}`)
|
|
134
|
+
.join('\n')
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
`\nStopped. These are finished and need no second run:\n${finished}\n` +
|
|
138
|
+
'Run telstore again with only the ids that are left — their .partial files are kept, ' +
|
|
139
|
+
'so those carry on where they stopped. "npx telstore status" shows what is unfinished.\n'
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// With onBackupId firing only once the .partial is open, an id here means there is a
|
|
144
|
+
// file to carry on from. Without one, this run stopped before it wrote anything, and
|
|
145
|
+
// saying a .partial was kept would be the same lie this message was rewritten to stop
|
|
146
|
+
// telling — just from the other side.
|
|
147
|
+
if (!backupId) {
|
|
148
|
+
return '\nStopped before anything was written. Run the same command again to start.\n'
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
`\nBackup ${backupId} kept its .partial file — run the same command again from this ` +
|
|
153
|
+
'directory to carry on, or "npx telstore status" to see what is left.\n'
|
|
154
|
+
)
|
|
125
155
|
}
|
|
126
156
|
|
|
127
157
|
// A delete has already destroyed messages for good by the time Ctrl-C lands, and the
|
package/src/commands/delete.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
|
|
1
3
|
import { chatName, describeChat } from '../chat.js'
|
|
2
4
|
import {
|
|
3
5
|
DELETE_BATCH_SIZE,
|
|
@@ -13,7 +15,7 @@ import { manifestFileName, manifestMessageIds, parseManifestJson } from '../mani
|
|
|
13
15
|
import { formatBytes, formatDuration } from '../progress.js'
|
|
14
16
|
import { assertLoggedIn } from '../session.js'
|
|
15
17
|
import { requireChat, resolveSettings } from '../settings.js'
|
|
16
|
-
import { clearState, findStates } from '../state.js'
|
|
18
|
+
import { clearRestore, clearState, findRestores, findStates } from '../state.js'
|
|
17
19
|
|
|
18
20
|
// What list prints when a card cannot be read back. A manifest is text off a chat, and a
|
|
19
21
|
// summary is not worth inventing: the numbers below only decorate a decision the backup id
|
|
@@ -223,6 +225,30 @@ export async function runDelete(backupId, options = {}, deps = {}) {
|
|
|
223
225
|
|
|
224
226
|
if (record) await clearState(record.key, configDir)
|
|
225
227
|
|
|
228
|
+
// The chunks are gone from the chat, so a restore record pointing at this backup now
|
|
229
|
+
// names messages nobody can fetch: `status` would keep offering a resume command that
|
|
230
|
+
// can only fail. Dropped here rather than earlier for the same reason the upload record
|
|
231
|
+
// is — anything that throws above leaves the way back intact.
|
|
232
|
+
//
|
|
233
|
+
// The .partial itself stays. It is the user's data, sometimes gigabytes of it, and this
|
|
234
|
+
// command removes what was asked for and nothing else. But it can never be completed
|
|
235
|
+
// now, so it is named on the way out: that is the difference between a file they can
|
|
236
|
+
// reclaim and one they will never think to look for.
|
|
237
|
+
const stranded = []
|
|
238
|
+
|
|
239
|
+
for (const found of await findRestores(backupId, configDir)) {
|
|
240
|
+
await clearRestore(found.key, configDir)
|
|
241
|
+
|
|
242
|
+
const partial = `${found.record.target}.partial`
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
await fs.stat(partial)
|
|
246
|
+
stranded.push(partial)
|
|
247
|
+
} catch {
|
|
248
|
+
// Nothing there to tell them about.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
226
252
|
if (manifestMessage) {
|
|
227
253
|
log(
|
|
228
254
|
`\nDone. Removed ${backupId} from ${chatName(chat)}: ` +
|
|
@@ -236,6 +262,13 @@ export async function runDelete(backupId, options = {}, deps = {}) {
|
|
|
236
262
|
)
|
|
237
263
|
}
|
|
238
264
|
|
|
265
|
+
for (const partial of stranded) {
|
|
266
|
+
log(
|
|
267
|
+
`${partial} is a half-finished restore of this backup. Nothing can finish it now — ` +
|
|
268
|
+
'delete it when you want the space back.',
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
239
272
|
return {
|
|
240
273
|
id: backupId,
|
|
241
274
|
chunks: chunkIds.length,
|
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 {
|
package/src/commands/status.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
|
|
1
3
|
import { countChunks } from '../chunking.js'
|
|
2
4
|
import { describeChat } from '../chat.js'
|
|
3
5
|
import { closeQuietly, connect as realConnect } from '../client.js'
|
|
@@ -5,7 +7,7 @@ import { configFile, defaultConfigDir, loadConfig } from '../config.js'
|
|
|
5
7
|
import { formatBytes } from '../progress.js'
|
|
6
8
|
import { assertLoggedIn } from '../session.js'
|
|
7
9
|
import { resolveSettings } from '../settings.js'
|
|
8
|
-
import { canResume, listStates } from '../state.js'
|
|
10
|
+
import { canResume, listRestores, listStates } from '../state.js'
|
|
9
11
|
|
|
10
12
|
const LABEL_WIDTH = 'Destination'.length + 2
|
|
11
13
|
|
|
@@ -52,6 +54,15 @@ const NO_RESUME = {
|
|
|
52
54
|
unreadable: 'the record does not name a file that can be read',
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
// Why the .partial cannot be resumed from, in the same spirit as NO_RESUME above: an
|
|
58
|
+
// EACCES or an ELOOP is not the same fact as the file being gone, and telling the user
|
|
59
|
+
// their multi-gigabyte download vanished when it is sitting there, unreadable, sends them
|
|
60
|
+
// looking for the wrong problem.
|
|
61
|
+
const NO_PARTIAL_RESUME = {
|
|
62
|
+
missing: 'the partial download is no longer there',
|
|
63
|
+
unreadable: 'the partial download cannot be read',
|
|
64
|
+
}
|
|
65
|
+
|
|
55
66
|
// A command is printed only when it will really resume. Printing one regardless would be
|
|
56
67
|
// telling the user to run something that quietly starts a second backup and abandons every
|
|
57
68
|
// chunk this one already sent — and those chunks are then findable only by this id, which
|
|
@@ -73,6 +84,44 @@ async function resumeLines(key, state, destination, done) {
|
|
|
73
84
|
return lines
|
|
74
85
|
}
|
|
75
86
|
|
|
87
|
+
// The record holds an absolute target, and printing it is what makes the pasted line
|
|
88
|
+
// correct. Without --out, runRestore resolves the manifest's own name against the current
|
|
89
|
+
// directory — a name status does not have (it is in a manifest on Telegram, and status
|
|
90
|
+
// reaches Telegram only for the account line, where it deliberately tolerates failure) and
|
|
91
|
+
// a directory status cannot assume. A command pasted from elsewhere would resolve to
|
|
92
|
+
// another path, miss the .partial and start over, which is the failure resume exists to end.
|
|
93
|
+
function restoreCommand(record, destination) {
|
|
94
|
+
const matches = destination !== null && record.chat === String(destination)
|
|
95
|
+
const chat = matches ? '' : ` --chat ${shellArg(record.chat)}`
|
|
96
|
+
|
|
97
|
+
return `npx telstore restore ${shellArg(record.id)} --out ${shellArg(record.target)}${chat}`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The .partial is the whole reason a resume is possible, so its absence is the one thing
|
|
101
|
+
// worth checking before offering a command that would silently start over.
|
|
102
|
+
async function restoreResumeLine(record, destination) {
|
|
103
|
+
try {
|
|
104
|
+
await fs.stat(`${record.target}.partial`)
|
|
105
|
+
} catch (err) {
|
|
106
|
+
const reason = err.code === 'ENOENT' ? 'missing' : 'unreadable'
|
|
107
|
+
|
|
108
|
+
return field('Resume', `not possible: ${NO_PARTIAL_RESUME[reason]}.`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return field('Resume', restoreCommand(record, destination))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Only the kinds actually present are named. "N backups" fits an upload and not a restore:
|
|
115
|
+
// there the backup is finished and sitting in the chat, and it is the restore that stopped.
|
|
116
|
+
function unfinishedCount(uploads, restores) {
|
|
117
|
+
const parts = []
|
|
118
|
+
|
|
119
|
+
if (uploads > 0) parts.push(`${uploads} upload${uploads === 1 ? '' : 's'}`)
|
|
120
|
+
if (restores > 0) parts.push(`${restores} restore${restores === 1 ? '' : 's'}`)
|
|
121
|
+
|
|
122
|
+
return parts.length === 0 ? 'none' : parts.join(', ')
|
|
123
|
+
}
|
|
124
|
+
|
|
76
125
|
function describeAccount(me) {
|
|
77
126
|
const name = [me.firstName, me.lastName].filter(Boolean).join(' ')
|
|
78
127
|
const handle = me.username ? ` (@${me.username})` : ''
|
|
@@ -153,24 +202,42 @@ export async function runStatus(options = {}, deps = {}) {
|
|
|
153
202
|
),
|
|
154
203
|
)
|
|
155
204
|
|
|
156
|
-
const
|
|
205
|
+
const uploads = await listStates(configDir)
|
|
206
|
+
const restores = await listRestores(configDir)
|
|
157
207
|
|
|
158
|
-
|
|
159
|
-
log(row('Unfinished', 'none'))
|
|
160
|
-
return
|
|
161
|
-
}
|
|
208
|
+
log(row('Unfinished', unfinishedCount(uploads.length, restores.length)))
|
|
162
209
|
|
|
163
|
-
|
|
210
|
+
if (uploads.length === 0 && restores.length === 0) return
|
|
164
211
|
|
|
165
|
-
// The destination is what decides whether
|
|
212
|
+
// The destination is what decides whether a resume command needs a --chat. A row that
|
|
166
213
|
// failed to parse leaves nothing to compare against, which is not the same as a match.
|
|
167
214
|
const destination = settings?.chat ?? null
|
|
168
215
|
|
|
169
|
-
|
|
216
|
+
// Newest first, by when the record last changed — which is when that transfer last made
|
|
217
|
+
// progress, and the same ordering pruneStates already means by "recent".
|
|
218
|
+
const entries = [
|
|
219
|
+
...uploads.map((entry) => ({ ...entry, kind: 'upload' })),
|
|
220
|
+
...restores.map((entry) => ({ ...entry, kind: 'restore' })),
|
|
221
|
+
].sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
222
|
+
|
|
223
|
+
for (const entry of entries) {
|
|
224
|
+
log('')
|
|
225
|
+
|
|
226
|
+
if (entry.kind === 'restore') {
|
|
227
|
+
const { record } = entry
|
|
228
|
+
|
|
229
|
+
log(` ${record.id}`)
|
|
230
|
+
log(field('File', `${record.target} (${formatBytes(record.size)})`))
|
|
231
|
+
log(field('Chunks', `${record.done ?? 0} of ${record.chunks ?? '?'} restored`))
|
|
232
|
+
log(field('Chat', describeChat(record.chat)))
|
|
233
|
+
log(await restoreResumeLine(record, destination))
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const { key, state } = entry
|
|
170
238
|
const total = countChunks(state.size, state.chunkSize)
|
|
171
239
|
const done = Object.keys(state.done ?? {}).length
|
|
172
240
|
|
|
173
|
-
log('')
|
|
174
241
|
log(` ${state.id}`)
|
|
175
242
|
log(field('File', `${state.path} (${formatBytes(state.size)})`))
|
|
176
243
|
log(field('Chunks', `${done} of ${total} uploaded`))
|
package/src/downloader.js
CHANGED
|
@@ -43,7 +43,10 @@ async function readExactly(fd, length, position) {
|
|
|
43
43
|
// the better check anyway: a slice written at the wrong offset, two slices overlapping, or
|
|
44
44
|
// one silently skipped all show up here. It does not prove the bytes reached the platter —
|
|
45
45
|
// this read may well be served from the page cache — it proves the assembly.
|
|
46
|
-
|
|
46
|
+
//
|
|
47
|
+
// Exported because a resumed restore asks the same question of a .partial left by an earlier
|
|
48
|
+
// run. A second copy of it in restore.js is how two definitions of one check start to differ.
|
|
49
|
+
export async function hashRange(fd, offset, length) {
|
|
47
50
|
const hash = createHash('sha256')
|
|
48
51
|
|
|
49
52
|
for (let at = 0; at < length; at += HASH_READ_SIZE) {
|
package/src/progress.js
CHANGED
package/src/state.js
CHANGED
|
@@ -16,9 +16,53 @@ export function stateFile(key, configDir = defaultConfigDir()) {
|
|
|
16
16
|
return path.join(stateDir(configDir), `${key}.json`)
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
// A restore's record is filed beside the uploads and must never compete with them for a
|
|
20
|
+
// prune slot. Losing an upload record strands chunks in a chat where only the id can still
|
|
21
|
+
// find them, which is why pruneStates reads each file back to name what it drops; losing a
|
|
22
|
+
// restore record costs one line of `status` for a .partial that still resumes perfectly.
|
|
23
|
+
// The name is what keeps them apart — an upload key is 40 hex characters and `r` is not
|
|
24
|
+
// hex, so the two namespaces cannot collide.
|
|
25
|
+
const RESTORE_PREFIX = 'restore-'
|
|
26
|
+
|
|
27
|
+
// stateKey's trick does not transfer: a .partial changes size and mtime on every write, so
|
|
28
|
+
// there is nothing there to key on. What holds still across runs is the backup being
|
|
29
|
+
// restored and the path being written.
|
|
30
|
+
export function restoreKey(backupId, absTarget) {
|
|
31
|
+
return createHash('sha1').update(`${backupId}:${absTarget}`).digest('hex')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function restoreFile(key, configDir = defaultConfigDir()) {
|
|
35
|
+
return path.join(stateDir(configDir), `${RESTORE_PREFIX}${key}.json`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// One reader for both kinds. A listing that forgets to filter hands `status` a restore
|
|
39
|
+
// record as though it were an upload, canResume stats a path that is not in it, and the
|
|
40
|
+
// report comes out wrong without anything failing — so there is one place that filters.
|
|
41
|
+
async function recordNames(configDir, restores) {
|
|
42
|
+
let names
|
|
43
|
+
try {
|
|
44
|
+
names = await fs.readdir(stateDir(configDir))
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err.code === 'ENOENT') return []
|
|
47
|
+
throw err
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return names.filter(
|
|
51
|
+
(name) => name.endsWith('.json') && name.startsWith(RESTORE_PREFIX) === restores,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function keyOfName(name) {
|
|
56
|
+
const base = name.slice(0, -'.json'.length)
|
|
57
|
+
|
|
58
|
+
return base.startsWith(RESTORE_PREFIX) ? base.slice(RESTORE_PREFIX.length) : base
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Why loadState returns null rather than throwing, in one place both kinds can use: one
|
|
62
|
+
// corrupt file must not hide the other records still waiting to be finished.
|
|
63
|
+
async function readRecord(file) {
|
|
20
64
|
try {
|
|
21
|
-
return JSON.parse(await fs.readFile(
|
|
65
|
+
return JSON.parse(await fs.readFile(file, 'utf8'))
|
|
22
66
|
} catch (err) {
|
|
23
67
|
if (err.code === 'ENOENT') return null
|
|
24
68
|
if (err instanceof SyntaxError) return null
|
|
@@ -26,6 +70,32 @@ export async function loadState(key, configDir = defaultConfigDir()) {
|
|
|
26
70
|
}
|
|
27
71
|
}
|
|
28
72
|
|
|
73
|
+
async function removeRecord(file) {
|
|
74
|
+
try {
|
|
75
|
+
await fs.unlink(file)
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (err.code !== 'ENOENT') throw err
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The mtime says when a record last made progress, which is the order `status` prints in —
|
|
82
|
+
// nothing decides from it whether a record exists. Its content has already been read by the
|
|
83
|
+
// time this runs, so a stat that fails must not drop the record or take the listing down:
|
|
84
|
+
// an unknown time sorts last and nothing is hidden. The file can genuinely vanish between
|
|
85
|
+
// the readdir and here, which is the case this exists for.
|
|
86
|
+
async function recordMtime(file) {
|
|
87
|
+
try {
|
|
88
|
+
const { mtimeMs } = await fs.stat(file)
|
|
89
|
+
return mtimeMs
|
|
90
|
+
} catch {
|
|
91
|
+
return 0
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function loadState(key, configDir = defaultConfigDir()) {
|
|
96
|
+
return await readRecord(stateFile(key, configDir))
|
|
97
|
+
}
|
|
98
|
+
|
|
29
99
|
export async function saveState(key, state, configDir = defaultConfigDir()) {
|
|
30
100
|
await writeJsonAtomic(stateFile(key, configDir), state)
|
|
31
101
|
}
|
|
@@ -37,11 +107,19 @@ export async function markChunkDone(key, state, i, entry, configDir = defaultCon
|
|
|
37
107
|
}
|
|
38
108
|
|
|
39
109
|
export async function clearState(key, configDir = defaultConfigDir()) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
110
|
+
await removeRecord(stateFile(key, configDir))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function loadRestore(key, configDir = defaultConfigDir()) {
|
|
114
|
+
return await readRecord(restoreFile(key, configDir))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function saveRestore(key, record, configDir = defaultConfigDir()) {
|
|
118
|
+
await writeJsonAtomic(restoreFile(key, configDir), record)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function clearRestore(key, configDir = defaultConfigDir()) {
|
|
122
|
+
await removeRecord(restoreFile(key, configDir))
|
|
45
123
|
}
|
|
46
124
|
|
|
47
125
|
// A state file is only useful while its backup can still be resumed, and nothing ever
|
|
@@ -54,24 +132,14 @@ export const MAX_STATES = 20
|
|
|
54
132
|
// were dropped: the caller says their ids out loud, because after this the id is the only
|
|
55
133
|
// way left to find those chunks in the chat.
|
|
56
134
|
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
135
|
const files = []
|
|
66
136
|
|
|
67
|
-
for (const name of
|
|
68
|
-
if (!name.endsWith('.json')) continue
|
|
69
|
-
|
|
137
|
+
for (const name of await recordNames(configDir, false)) {
|
|
70
138
|
const file = path.join(stateDir(configDir), name)
|
|
71
139
|
|
|
72
140
|
try {
|
|
73
141
|
const stat = await fs.stat(file)
|
|
74
|
-
files.push({ key: name
|
|
142
|
+
files.push({ key: keyOfName(name), file, mtimeMs: stat.mtimeMs })
|
|
75
143
|
} catch (err) {
|
|
76
144
|
// Gone between readdir and stat: nothing left to prune.
|
|
77
145
|
if (err.code !== 'ENOENT') throw err
|
|
@@ -99,25 +167,19 @@ export async function pruneStates(configDir = defaultConfigDir(), keep = MAX_STA
|
|
|
99
167
|
//
|
|
100
168
|
// The key comes back alongside each record because canResume needs it, and the file name is
|
|
101
169
|
// the only place it survives: the record's own path, size and mtime are exactly what a
|
|
102
|
-
// rewritten file makes stale, so recomputing the key from them would always say yes.
|
|
170
|
+
// rewritten file makes stale, so recomputing the key from them would always say yes. The
|
|
171
|
+
// mtime comes back because it is when this backup last made progress, which is the order
|
|
172
|
+
// status prints records in.
|
|
103
173
|
export async function listStates(configDir = defaultConfigDir()) {
|
|
104
|
-
let names
|
|
105
|
-
try {
|
|
106
|
-
names = await fs.readdir(stateDir(configDir))
|
|
107
|
-
} catch (err) {
|
|
108
|
-
if (err.code === 'ENOENT') return []
|
|
109
|
-
throw err
|
|
110
|
-
}
|
|
111
|
-
|
|
112
174
|
const states = []
|
|
113
175
|
|
|
114
|
-
for (const name of
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const key = name.slice(0, -'.json'.length)
|
|
176
|
+
for (const name of await recordNames(configDir, false)) {
|
|
177
|
+
const key = keyOfName(name)
|
|
118
178
|
const state = await loadState(key, configDir)
|
|
119
179
|
|
|
120
|
-
if (state)
|
|
180
|
+
if (!state) continue
|
|
181
|
+
|
|
182
|
+
states.push({ key, state, mtimeMs: await recordMtime(path.join(stateDir(configDir), name)) })
|
|
121
183
|
}
|
|
122
184
|
|
|
123
185
|
return states
|
|
@@ -134,20 +196,10 @@ export async function listStates(configDir = defaultConfigDir()) {
|
|
|
134
196
|
// cannot know which to drop, and that is the caller's decision to refuse, not ours to make
|
|
135
197
|
// by picking one.
|
|
136
198
|
export async function findStates(backupId, configDir = defaultConfigDir()) {
|
|
137
|
-
let names
|
|
138
|
-
try {
|
|
139
|
-
names = await fs.readdir(stateDir(configDir))
|
|
140
|
-
} catch (err) {
|
|
141
|
-
if (err.code === 'ENOENT') return []
|
|
142
|
-
throw err
|
|
143
|
-
}
|
|
144
|
-
|
|
145
199
|
const found = []
|
|
146
200
|
|
|
147
|
-
for (const name of
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const key = name.slice(0, -'.json'.length)
|
|
201
|
+
for (const name of await recordNames(configDir, false)) {
|
|
202
|
+
const key = keyOfName(name)
|
|
151
203
|
const state = await loadState(key, configDir)
|
|
152
204
|
|
|
153
205
|
if (state?.id === backupId) found.push({ key, file: stateFile(key, configDir), state })
|
|
@@ -180,3 +232,67 @@ export async function canResume(key, state) {
|
|
|
180
232
|
|
|
181
233
|
return { ok: true }
|
|
182
234
|
}
|
|
235
|
+
|
|
236
|
+
export const MAX_RESTORES = 20
|
|
237
|
+
|
|
238
|
+
// A record with no id or no target can neither be printed nor resumed from, so status has
|
|
239
|
+
// nothing to do with it. Skipped rather than rendered with blanks: these files are
|
|
240
|
+
// hand-editable, and a row that names nothing is worse than no row.
|
|
241
|
+
export async function listRestores(configDir = defaultConfigDir()) {
|
|
242
|
+
const restores = []
|
|
243
|
+
|
|
244
|
+
for (const name of await recordNames(configDir, true)) {
|
|
245
|
+
const key = keyOfName(name)
|
|
246
|
+
const record = await loadRestore(key, configDir)
|
|
247
|
+
|
|
248
|
+
if (typeof record?.id !== 'string' || typeof record?.target !== 'string') continue
|
|
249
|
+
|
|
250
|
+
restores.push({ key, record, mtimeMs: await recordMtime(path.join(stateDir(configDir), name)) })
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return restores
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Unlike pruneStates this returns nothing and reads nothing back. Dropping a restore record
|
|
257
|
+
// strands no data — the .partial it points at still resumes, because the evidence for a
|
|
258
|
+
// resume was never in the record — so there is nothing to announce and no reason to open
|
|
259
|
+
// each file just to name it. It removes the signpost, never the .partial: a multi-gigabyte
|
|
260
|
+
// file must not disappear as a side effect of starting an unrelated restore.
|
|
261
|
+
export async function pruneRestores(configDir = defaultConfigDir(), keep = MAX_RESTORES) {
|
|
262
|
+
const files = []
|
|
263
|
+
|
|
264
|
+
for (const name of await recordNames(configDir, true)) {
|
|
265
|
+
const file = path.join(stateDir(configDir), name)
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const stat = await fs.stat(file)
|
|
269
|
+
files.push({ file, mtimeMs: stat.mtimeMs })
|
|
270
|
+
} catch (err) {
|
|
271
|
+
if (err.code !== 'ENOENT') throw err
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
files.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
276
|
+
|
|
277
|
+
for (const { file } of files.slice(keep)) await removeRecord(file)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// What findStates is for uploads, and for the same reason: the file name hashes the target
|
|
281
|
+
// path, which delete does not know — it has an id and nothing else. Matching the id inside
|
|
282
|
+
// each file is the only way that cannot point at the wrong one.
|
|
283
|
+
//
|
|
284
|
+
// Every record claiming the id comes back, not the first. One backup restored to two places
|
|
285
|
+
// is two records, and a delete that drops one of them leaves a signpost to chunks that are
|
|
286
|
+
// no longer in the chat.
|
|
287
|
+
export async function findRestores(backupId, configDir = defaultConfigDir()) {
|
|
288
|
+
const found = []
|
|
289
|
+
|
|
290
|
+
for (const name of await recordNames(configDir, true)) {
|
|
291
|
+
const key = keyOfName(name)
|
|
292
|
+
const record = await loadRestore(key, configDir)
|
|
293
|
+
|
|
294
|
+
if (record?.id === backupId) found.push({ key, file: restoreFile(key, configDir), record })
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return found
|
|
298
|
+
}
|