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.
@@ -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 states = await listStates(configDir)
205
+ const uploads = await listStates(configDir)
206
+ const restores = await listRestores(configDir)
157
207
 
158
- if (states.length === 0) {
159
- log(row('Unfinished', 'none'))
160
- return
161
- }
208
+ log(row('Unfinished', unfinishedCount(uploads.length, restores.length)))
162
209
 
163
- log(row('Unfinished', `${states.length} backup${states.length === 1 ? '' : 's'}`))
210
+ if (uploads.length === 0 && restores.length === 0) return
164
211
 
165
- // The destination is what decides whether the resume command needs a --chat. A row that
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
- for (const { key, state } of states) {
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
- async function hashRange(fd, offset, length) {
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}.manifest.json`
24
+ return `${id}${MANIFEST_SUFFIX}`
20
25
  }
21
26
 
22
27
  export function buildManifest({
package/src/progress.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const UNITS = ['B', 'KB', 'MB', 'GB', 'TB']
2
2
 
3
3
  export function formatBytes(n) {
4
+ if (!Number.isFinite(n)) return '--'
4
5
  if (n < 1024) return `${n} B`
5
6
 
6
7
  let value = n