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/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`))
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { chatName, describeChat } from '../chat.js'
|
|
2
|
+
import {
|
|
3
|
+
MESSAGE_BATCH_SIZE,
|
|
4
|
+
closeQuietly,
|
|
5
|
+
connect as realConnect,
|
|
6
|
+
documentFileName,
|
|
7
|
+
documentSize,
|
|
8
|
+
findManifestMessage,
|
|
9
|
+
getDocuments as realGetDocuments,
|
|
10
|
+
readMessageBytes as realReadMessageBytes,
|
|
11
|
+
} from '../client.js'
|
|
12
|
+
import { configFile, defaultConfigDir, loadConfig } from '../config.js'
|
|
13
|
+
import { chunkFileName, manifestFileName, parseManifest } from '../manifest.js'
|
|
14
|
+
import { formatBytes, formatDuration } from '../progress.js'
|
|
15
|
+
import { assertLoggedIn } from '../session.js'
|
|
16
|
+
import { requireChat, resolveSettings } from '../settings.js'
|
|
17
|
+
|
|
18
|
+
// parseManifest guarantees every number in a manifest, but not the file name — it is
|
|
19
|
+
// decoration, and nothing verifies differently because of it. It is still text off a chat.
|
|
20
|
+
function describeName(name) {
|
|
21
|
+
return typeof name === 'string' && name.trim() !== '' ? name : '—'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function plural(n, word) {
|
|
25
|
+
return `${n} ${word}${n === 1 ? '' : 's'}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// What is wrong with one chunk, or null when nothing is. The first failing check wins: a
|
|
29
|
+
// chunk is damaged or it is not, and listing three complaints about one message would make
|
|
30
|
+
// "2 damaged" mean something other than two chunks.
|
|
31
|
+
//
|
|
32
|
+
// Every question here is one the chat can answer without sending a byte of the file. What
|
|
33
|
+
// this cannot ask is whether the bytes inside are the bytes that went up — only downloading
|
|
34
|
+
// them answers that, which is why the closing line says so rather than leaving it implied.
|
|
35
|
+
function inspect(chunk, message, { backupId, total, chat }) {
|
|
36
|
+
const at = `Chunk ${chunk.i + 1}/${total}`
|
|
37
|
+
|
|
38
|
+
if (!message) {
|
|
39
|
+
return `${at} is gone: message ${chunk.msgId} is no longer in ${chatName(chat)}.`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const size = documentSize(message)
|
|
43
|
+
|
|
44
|
+
if (size === null) {
|
|
45
|
+
return `${at} is message ${chunk.msgId}, which has no file attached.`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const wanted = chunkFileName(backupId, chunk.i)
|
|
49
|
+
const fileName = documentFileName(message)
|
|
50
|
+
|
|
51
|
+
if (fileName !== wanted) {
|
|
52
|
+
return fileName === null
|
|
53
|
+
? `${at} is message ${chunk.msgId}, whose file carries no name; the manifest expects ` +
|
|
54
|
+
`"${wanted}".`
|
|
55
|
+
: `${at} is message ${chunk.msgId}, which carries the file name ` +
|
|
56
|
+
`${JSON.stringify(fileName)} rather than "${wanted}".`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (size !== chunk.size) {
|
|
60
|
+
return `${at} is ${size} bytes in the chat, the manifest records ${chunk.size}.`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function runVerify(backupId, options = {}, deps = {}) {
|
|
67
|
+
const {
|
|
68
|
+
connect = realConnect,
|
|
69
|
+
disconnect = (client) => client.destroy(),
|
|
70
|
+
configDir = defaultConfigDir(),
|
|
71
|
+
searchManifest = findManifestMessage,
|
|
72
|
+
readMessageBytes = realReadMessageBytes,
|
|
73
|
+
getDocuments = realGetDocuments,
|
|
74
|
+
retryOptions = {},
|
|
75
|
+
writeErr = (line) => process.stderr.write(line),
|
|
76
|
+
log: writeLog = (line) => console.log(line),
|
|
77
|
+
silent = false,
|
|
78
|
+
} = deps
|
|
79
|
+
|
|
80
|
+
const config = await loadConfig(configDir)
|
|
81
|
+
const { values: settings } = resolveSettings(options, config, { file: configFile(configDir) })
|
|
82
|
+
// Ask about the login before the destination, as list does: telling someone who has never
|
|
83
|
+
// logged in to pick a chat sends them off after the wrong thing.
|
|
84
|
+
assertLoggedIn(config)
|
|
85
|
+
const chat = requireChat(settings)
|
|
86
|
+
|
|
87
|
+
const log = silent ? () => {} : writeLog
|
|
88
|
+
const warn = silent ? () => {} : writeErr
|
|
89
|
+
|
|
90
|
+
// Upload and restore stay quiet until the third retry so a handful of -503s do not bury
|
|
91
|
+
// the progress bar. There is no bar here to bury — this command sends a handful of small
|
|
92
|
+
// requests and prints one verdict — so a wait long enough to notice is announced at once.
|
|
93
|
+
function onRetry(err, attempt, delayMs) {
|
|
94
|
+
warn(
|
|
95
|
+
`\nTemporary error (${err.message}), retry ${attempt} in ` +
|
|
96
|
+
`${formatDuration(delayMs / 1000)}.\n`,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const client = await connect(config, { verbose: settings.verbose })
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const manifestMessage = await searchManifest(client, chat, backupId)
|
|
104
|
+
|
|
105
|
+
if (!manifestMessage) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`No backup ${backupId} found in ${chatName(chat)}. Check the id with ` +
|
|
108
|
+
'"npx telstore list", or use --chat to point at the right chat.',
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The full layout checks, not the lenient path delete takes. delete reads a manifest to
|
|
113
|
+
// destroy what it names, so a broken one is exactly what somebody is there to remove;
|
|
114
|
+
// verify reads it to answer whether restore would work, and restore would refuse this
|
|
115
|
+
// one. Saying so in parseManifest's own words keeps one description of one fault.
|
|
116
|
+
const manifest = parseManifest(await readMessageBytes(client, manifestMessage))
|
|
117
|
+
|
|
118
|
+
// The same refusal delete makes, for the same reason from the other side: the manifest
|
|
119
|
+
// was found by the file name telstore wrote, so a body naming another backup is a file
|
|
120
|
+
// that was renamed or replaced, and its message ids describe somebody else's chunks.
|
|
121
|
+
// Reporting those as this backup's health is the one wrong answer this command can give.
|
|
122
|
+
if (manifest.id !== undefined && manifest.id !== backupId) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`The manifest named ${manifestFileName(backupId)} describes backup ` +
|
|
125
|
+
`${JSON.stringify(manifest.id)}, not ${backupId}. Its message ids point at another ` +
|
|
126
|
+
`backup's chunks, so telstore cannot say whether ${backupId} is still there.`,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const total = manifest.chunks.length
|
|
131
|
+
|
|
132
|
+
log(`Backup ${backupId}`)
|
|
133
|
+
log(
|
|
134
|
+
`File ${describeName(manifest.name)} ` +
|
|
135
|
+
`(${formatBytes(manifest.size)}, ${plural(total, 'chunk')})`,
|
|
136
|
+
)
|
|
137
|
+
log(`In ${describeChat(chat)}`)
|
|
138
|
+
log('')
|
|
139
|
+
|
|
140
|
+
// A backup at the 10000-chunk ceiling is a hundred requests, sent one at a time. Silence
|
|
141
|
+
// for that long reads as a hang, which is the one thing no wait in this project may look
|
|
142
|
+
// like — and below a single request there is no progress worth a line.
|
|
143
|
+
const loud = total > MESSAGE_BATCH_SIZE
|
|
144
|
+
|
|
145
|
+
const found = await getDocuments(
|
|
146
|
+
client,
|
|
147
|
+
chat,
|
|
148
|
+
manifest.chunks.map((chunk) => chunk.msgId),
|
|
149
|
+
{
|
|
150
|
+
retryOptions: { ...retryOptions, onRetry },
|
|
151
|
+
onBatch: (done, all) => {
|
|
152
|
+
if (loud) warn(`\rChecking chunk messages ${done}/${all}…`)
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if (loud) warn('\n')
|
|
158
|
+
|
|
159
|
+
const damaged = []
|
|
160
|
+
|
|
161
|
+
for (const chunk of manifest.chunks) {
|
|
162
|
+
const fault = inspect(chunk, found.get(chunk.msgId), { backupId, total, chat })
|
|
163
|
+
|
|
164
|
+
if (fault) {
|
|
165
|
+
damaged.push(fault)
|
|
166
|
+
log(fault)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (damaged.length > 0) {
|
|
171
|
+
log('')
|
|
172
|
+
log(
|
|
173
|
+
`${plural(total, 'chunk')} checked, ${damaged.length} damaged. ` +
|
|
174
|
+
'This backup cannot be restored.',
|
|
175
|
+
)
|
|
176
|
+
} else {
|
|
177
|
+
log(
|
|
178
|
+
`${plural(total, 'chunk')} present, at the ` +
|
|
179
|
+
`${total === 1 ? 'size' : 'sizes'} the manifest records.`,
|
|
180
|
+
)
|
|
181
|
+
log('This does not download them, so it cannot prove their contents.')
|
|
182
|
+
log('')
|
|
183
|
+
log(`Restore with: npx telstore restore ${backupId}`)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { id: backupId, name: manifest.name, chunks: total, damaged }
|
|
187
|
+
} finally {
|
|
188
|
+
await closeQuietly(client, disconnect)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// A backup is a failure whether telstore could not look it up or looked and found it broken.
|
|
193
|
+
// The command worked either way — but "did the run find everything it was asked to check"
|
|
194
|
+
// is the question the exit code answers, and both answers to that are no.
|
|
195
|
+
function isFailure(result) {
|
|
196
|
+
return Boolean(result.error) || result.damaged.length > 0
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The same shape as runDeletes and runRestores, minus the question: verify removes nothing,
|
|
200
|
+
// so there is nothing to authorise. What is knowable before the connection is refused up
|
|
201
|
+
// front; what only the chat can answer is per id, named when it happens and again at the end.
|
|
202
|
+
export async function runVerifies(backupIds, options = {}, deps = {}) {
|
|
203
|
+
const {
|
|
204
|
+
connect = realConnect,
|
|
205
|
+
disconnect = (client) => client.destroy(),
|
|
206
|
+
configDir = defaultConfigDir(),
|
|
207
|
+
writeErr = (line) => process.stderr.write(line),
|
|
208
|
+
log: writeLog = (line) => console.log(line),
|
|
209
|
+
silent = false,
|
|
210
|
+
} = deps
|
|
211
|
+
|
|
212
|
+
// One id keeps its own wording and its own thrown error. A summary about one backup only
|
|
213
|
+
// repeats the lines above it.
|
|
214
|
+
if (backupIds.length === 1) {
|
|
215
|
+
const result = await runVerify(backupIds[0], options, deps)
|
|
216
|
+
return { results: [result], failed: isFailure(result) ? 1 : 0 }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const duplicate = backupIds.find((id, index) => backupIds.indexOf(id) !== index)
|
|
220
|
+
|
|
221
|
+
if (duplicate) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`${duplicate} is named twice. Checking one backup twice asks the chat the same ` +
|
|
224
|
+
'question again — name it once.',
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const config = await loadConfig(configDir)
|
|
229
|
+
|
|
230
|
+
assertLoggedIn(config)
|
|
231
|
+
|
|
232
|
+
const { values: settings } = resolveSettings(options, config, { file: configFile(configDir) })
|
|
233
|
+
const chat = requireChat(settings)
|
|
234
|
+
|
|
235
|
+
const log = silent ? () => {} : writeLog
|
|
236
|
+
const warn = silent ? () => {} : writeErr
|
|
237
|
+
|
|
238
|
+
let shared = null
|
|
239
|
+
const perId = {
|
|
240
|
+
...deps,
|
|
241
|
+
connect: async (theirConfig, connectOptions) =>
|
|
242
|
+
(shared ??= await connect(theirConfig, connectOptions)),
|
|
243
|
+
disconnect: async () => {},
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const results = []
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
await perId.connect(config, { verbose: settings.verbose })
|
|
250
|
+
|
|
251
|
+
for (const [index, backupId] of backupIds.entries()) {
|
|
252
|
+
if (index > 0) log('')
|
|
253
|
+
log(`[${index + 1}/${backupIds.length}] ${backupId}`)
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
results.push(await runVerify(backupId, options, perId))
|
|
257
|
+
} catch (err) {
|
|
258
|
+
// Unlike delete, an id nothing knows about does not stop the run: nothing here is
|
|
259
|
+
// destroyed, and the other ids are exactly the ones somebody is checking on.
|
|
260
|
+
results.push({ id: backupId, error: err.message, damaged: [] })
|
|
261
|
+
warn(`\n${backupId} failed: ${err.message}\n`)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
} finally {
|
|
265
|
+
if (shared) {
|
|
266
|
+
await closeQuietly(shared, disconnect, (err) =>
|
|
267
|
+
warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`),
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const failed = results.filter(isFailure).length
|
|
273
|
+
|
|
274
|
+
log('')
|
|
275
|
+
for (const line of summaryLines(results, failed)) log(line)
|
|
276
|
+
|
|
277
|
+
return { results, failed }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Every id gets a line whether it checked out or not: one missing from this list would be a
|
|
281
|
+
// backup nobody could tell the state of, which is the whole reason this command exists.
|
|
282
|
+
function summaryLines(results, failed) {
|
|
283
|
+
const width = Math.max(...results.map((result) => result.id.length))
|
|
284
|
+
|
|
285
|
+
return [
|
|
286
|
+
`${results.length} backups: ${results.length - failed} verified, ${failed} failed.`,
|
|
287
|
+
'',
|
|
288
|
+
...results.map((result) => {
|
|
289
|
+
const id = result.id.padEnd(width)
|
|
290
|
+
|
|
291
|
+
if (result.error) return ` ${id} failed: ${result.error}`
|
|
292
|
+
|
|
293
|
+
return result.damaged.length > 0
|
|
294
|
+
? ` ${id} ${plural(result.damaged.length, 'chunk')} damaged`
|
|
295
|
+
: ` ${id} ${plural(result.chunks, 'chunk')} present`
|
|
296
|
+
}),
|
|
297
|
+
]
|
|
298
|
+
}
|
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/manifest.js
CHANGED
|
@@ -15,8 +15,13 @@ export function chunkFileName(id, i) {
|
|
|
15
15
|
return `${id}.part${String(i + 1).padStart(4, '0')}`
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// The suffix telstore has written on every manifest since version 1, and what `list` picks
|
|
19
|
+
// a manifest out of a chat by. One definition, because a reader that disagrees with the
|
|
20
|
+
// writer by one character finds nothing at all.
|
|
21
|
+
export const MANIFEST_SUFFIX = '.manifest.json'
|
|
22
|
+
|
|
18
23
|
export function manifestFileName(id) {
|
|
19
|
-
return `${id}
|
|
24
|
+
return `${id}${MANIFEST_SUFFIX}`
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
export function buildManifest({
|