telstore 0.1.9 → 0.1.10

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.
@@ -0,0 +1,459 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import { MAX_CHUNKS, PART_SIZE } from '../chunking.js'
5
+ import { chunkCaption, manifestCaption, parseNote } from '../caption.js'
6
+ import { chatName, describeChat } from '../chat.js'
7
+ import {
8
+ MESSAGE_BATCH_SIZE,
9
+ closeQuietly,
10
+ connect as realConnect,
11
+ deleteMessages as realDeleteMessages,
12
+ } from '../client.js'
13
+ import { configFile, defaultConfigDir, loadConfig } from '../config.js'
14
+ import {
15
+ buildManifest,
16
+ chunkFileName,
17
+ manifestFileName,
18
+ newBackupId,
19
+ serializeManifest,
20
+ } from '../manifest.js'
21
+ import { createStreamProgress, formatBytes, plural } from '../progress.js'
22
+ import { requireChat, resolveSettings } from '../settings.js'
23
+ import { deleteCommand } from '../shell.js'
24
+ import { spawnProducer } from '../spawn.js'
25
+ import {
26
+ MAX_STATES,
27
+ clearState,
28
+ markChunkDone,
29
+ pruneStates,
30
+ saveState,
31
+ streamKey,
32
+ tempDirFor,
33
+ } from '../state.js'
34
+ import { ChunkReader, discardChunkFile } from '../stream.js'
35
+ import { uploadRange } from '../uploader.js'
36
+ import { createOnRetry, realSendChunk, realSendManifest } from './upload.js'
37
+
38
+ // `telstore a.tar -- tar cf - ./a`: the backup's bytes are what the command writes, and the
39
+ // name is a label, not a file telstore reads.
40
+ //
41
+ // This is runUpload with the one thing it leans on taken away — a length known up front — so
42
+ // there is no planChunks, no resume, no re-stat, and no total on the bar. What replaces the
43
+ // re-stat is the child's exit code, which is the whole reason telstore spawns the command
44
+ // instead of reading a pipe.
45
+ export async function runStreamUpload(name, childArgv, options = {}, deps = {}) {
46
+ const {
47
+ connect = realConnect,
48
+ sendChunk = realSendChunk,
49
+ sendManifest = realSendManifest,
50
+ disconnect = (client) => client.destroy(),
51
+ configDir = defaultConfigDir(),
52
+ partSize = PART_SIZE,
53
+ // Through deps rather than read straight off the constant: this branch only fires after
54
+ // ten thousand chunks have gone out, and a limit a test cannot lower is a limit no test
55
+ // will ever reach.
56
+ maxChunks = MAX_CHUNKS,
57
+ spawn = spawnProducer,
58
+ deleteMessages = realDeleteMessages,
59
+ retryOptions = {},
60
+ writeErr = (line) => process.stderr.write(line),
61
+ log: writeLog = (line) => console.log(line),
62
+ silent = false,
63
+ onBackupId = () => {},
64
+ // How Ctrl-C reaches a run that must not be killed where it stands. See the call below.
65
+ onAbortable = () => {},
66
+ // Which chunk file this run is holding, so the one ending that does not come back through
67
+ // `discardChunkFile` can still take it with it. Said before the file is opened and unsaid
68
+ // after it is removed, so the caller's copy is never narrower than what is actually on disk.
69
+ onTempChunk = () => {},
70
+ } = deps
71
+
72
+ // Before the command is started, let alone connected to Telegram: the note is the one thing
73
+ // this run sends that a person typed by hand, and it goes into the manifest, which goes out
74
+ // last. A note Telegram would refuse has to stop the run here, not after an hour of pg_dump.
75
+ const note = parseNote(options.note)
76
+
77
+ const config = await loadConfig(configDir)
78
+ const { values: settings } = resolveSettings(options, config, { file: configFile(configDir) })
79
+ const chat = requireChat(settings)
80
+ const chunkSize = settings.chunkSize
81
+ const concurrency = settings.uploadConcurrency
82
+
83
+ const id = newBackupId()
84
+ const key = streamKey(id)
85
+
86
+ // A stream record exists for one reason: to say what is already in the chat when the run
87
+ // fails, because nothing else will ever point at those chunks. There is no path, size or
88
+ // mtime to key it on and nothing to resume onto — `kind` is what tells status and delete so.
89
+ let state = { v: 1, kind: 'stream', id, chat: String(chat), name, chunkSize, done: {} }
90
+
91
+ await saveState(key, state, configDir)
92
+
93
+ // Every stream upload adds to the directory, so this is where it can grow. The report goes
94
+ // out even when the caller asked for silence: this is not narration about a transfer, it is
95
+ // telstore dropping the only record of someone else's chunks.
96
+ for (const gone of await pruneStates(configDir)) {
97
+ writeErr(
98
+ `\nDropped the record of unfinished backup ${gone.id}: telstore keeps the ` +
99
+ `${MAX_STATES} most recent. The chunks it sent are still in ${gone.chat}, ` +
100
+ 'searchable by that id, but that backup can no longer be resumed.\n',
101
+ )
102
+ }
103
+
104
+ onBackupId(id)
105
+
106
+ // The producer and the reader are named here rather than where they are made, because the
107
+ // abort below has to be handed out before either exists.
108
+ let aborted = false
109
+ let child = null
110
+ let reader = null
111
+
112
+ // A run whose chunks are removed when it fails cannot be killed where it stands: exiting at
113
+ // the signal would leave in the chat exactly the chunks this command promises never to
114
+ // leave, and removing them is a network round trip per batch. So Ctrl-C asks the run to
115
+ // stop instead, through this, and waits for the rollback below to finish.
116
+ //
117
+ // Handed over before connect, not after the child is spawned: from the moment the record
118
+ // exists there is something a Ctrl-C has to unwind, and a caller that has not been given
119
+ // this yet has no choice but to exit on the spot.
120
+ onAbortable(async () => {
121
+ aborted = true
122
+
123
+ // The kill stops the producer; closing the reader is what unblocks a fill still waiting
124
+ // on a pipe the child is never going to write to again.
125
+ if (child) child.kill()
126
+ if (reader) await reader.close()
127
+ }, { chat })
128
+
129
+ // The one error the caller is meant to recognise: a run that stopped because someone asked
130
+ // it to has nothing to report that Ctrl-C did not already say. A rollback that could not
131
+ // finish throws a fresh error of its own instead, and that one is still a failure.
132
+ function stopped() {
133
+ const err = new Error('Stopped before the backup was finished.')
134
+ err.interrupted = true
135
+ return err
136
+ }
137
+
138
+ const log = silent ? () => {} : writeLog
139
+ const warn = silent ? () => {} : writeErr
140
+ const onRetry = createOnRetry(warn)
141
+
142
+ // Every message id this run has put in the chat, in the order it put them there. The record
143
+ // on disk is the durable copy, for the run that dies without getting this far; this one is
144
+ // what rollback reaches for, because it is right even when the write to disk is the thing
145
+ // that failed.
146
+ const sent = []
147
+ let client = null
148
+
149
+ // A file upload keeps its chunks on purpose — a second run resumes onto them. A stream
150
+ // cannot be resumed: the bytes have gone past, and the next run cuts them differently. So a
151
+ // chunk left in the chat by a failed stream is a chunk nothing will ever point at again,
152
+ // sitting in somebody's Telegram with no manifest naming it. Removing them is part of
153
+ // failing, not a courtesy. Always throws.
154
+ async function rollback(err) {
155
+ if (sent.length === 0) {
156
+ // Cleared even when it names nothing. An empty record is useless, but it still counts
157
+ // against MAX_STATES exactly as a full one does — so leaving it behind can evict the
158
+ // record of a real upload whose chunks are still in a chat, which is the loss
159
+ // pruneStates goes out of its way to announce.
160
+ await clearState(key, configDir)
161
+ throw err
162
+ }
163
+
164
+ // "message" rather than "chunk": the manifest joins this list on the narrow path where a
165
+ // run fails after sending it, and a count that says "3 chunks" for two chunks and a card
166
+ // is telstore describing the chat wrongly in the one report someone reads closely.
167
+ warn(`\nRemoving the ${plural(sent.length, 'message')} this run already sent...\n`)
168
+
169
+ const loud = sent.length > MESSAGE_BATCH_SIZE
170
+ let removed = 0
171
+
172
+ try {
173
+ await deleteMessages(client, chat, sent, {
174
+ retryOptions: { ...retryOptions, onRetry },
175
+ onBatch: (done, total) => {
176
+ removed = done
177
+ if (loud) warn(`\rRemoving messages ${done}/${total}…`)
178
+ },
179
+ })
180
+ } catch (cleanupErr) {
181
+ // The record stays, and deliberately: it is the only list of these message ids, since
182
+ // there is no manifest in the chat and there never will be one. `delete` reads exactly
183
+ // this record through findStates when it finds no manifest, which is why that is the
184
+ // command to name. The original failure is still said first — the rollback is what
185
+ // happened next, not what went wrong.
186
+ //
187
+ // The chat is always named, where `status` leaves --chat out of a *resume* when it
188
+ // matches the destination in force. status compares against the destination its own run
189
+ // resolved, which is the one the pasted command will resolve too. Here the destination
190
+ // in force may have come from a --chat on this command line, which the later `delete`
191
+ // will not carry: it would resolve its own chat from config and fire these ids at that
192
+ // peer instead, destroying whatever happens to carry them there. `deleteCommand` holds
193
+ // that rule for every place that prints this line; `chat` is required by the time a run
194
+ // has sent anything, so the chatless branch is not reachable from here.
195
+ throw new Error(
196
+ `${err.message}\n\ntelstore then removed ${removed} of the ` +
197
+ `${plural(sent.length, 'message')} it had sent before Telegram refused: ` +
198
+ `${cleanupErr.message}. The rest are still in ${chatName(chat)}. The local record ` +
199
+ 'was left in place on purpose — it is the only list of them on this machine. Run ' +
200
+ `"${deleteCommand(id, chat)}" to remove them.`,
201
+ )
202
+ }
203
+
204
+ if (loud) warn('\n')
205
+
206
+ await clearState(key, configDir)
207
+
208
+ throw err
209
+ }
210
+
211
+ log(`Backup ${id}`)
212
+ log(`Name ${name} (chunks of ${formatBytes(chunkSize)})`)
213
+ log(`From ${childArgv.join(' ')}`)
214
+ log(`To ${describeChat(chat)}\n`)
215
+
216
+ try {
217
+ client = await connect(config, { verbose: settings.verbose })
218
+
219
+ // Connecting is the one stretch long enough for Ctrl-C to arrive before the command has
220
+ // been started, and starting someone's command after they asked telstore to stop is the
221
+ // one thing a wait must not turn into.
222
+ if (aborted) throw stopped()
223
+
224
+ const tmp = tempDirFor(configDir)
225
+ await fs.mkdir(tmp, { recursive: true })
226
+
227
+ child = spawn(childArgv)
228
+ reader = new ChunkReader(child.stdout)
229
+
230
+ let size = 0
231
+ let count = 0
232
+ let ended = false
233
+ let progress = null
234
+
235
+ try {
236
+ try {
237
+ for (;;) {
238
+ const file = path.join(tmp, `${id}-${count}.chunk`)
239
+
240
+ // Said before the open rather than after it, so the handler holds the name for the
241
+ // whole window in which a file can exist: 'w+' creates it, and a second Ctrl-C
242
+ // landing while the open is in flight would otherwise find nothing to remove.
243
+ // Unlinking a file that was never made is an ENOENT the caller ignores.
244
+ //
245
+ // What this does not cover, and it looks as though it should: an open that creates
246
+ // the file and then throws leaves the loop without entering the `try` below, so the
247
+ // `finally` that removes it never runs and nothing on that path reads the name back.
248
+ // `status` is what catches that one, with every other ending no handler sees.
249
+ onTempChunk(file)
250
+
251
+ const handle = await fs.open(file, 'w+')
252
+ let eof = false
253
+
254
+ try {
255
+ // Nothing pulls ahead of this call, which is what stops the child running away
256
+ // with the pipe while a chunk spends three minutes uploading: the backlog waits
257
+ // in the kernel's buffer and in the child, not in this process's memory.
258
+ const filled = await reader.fill(handle, chunkSize)
259
+ eof = filled.eof
260
+
261
+ // An abort that lands while this fill was waiting must not become one more chunk
262
+ // in the chat. The rollback below would remove it again, but not before minutes
263
+ // of uploading had gone by with the bar still moving, in front of the person who
264
+ // asked telstore to stop.
265
+ if (aborted) throw stopped()
266
+
267
+ if (filled.bytes > 0) {
268
+ // Asked of bytes that actually arrived, not of the count alone. A stream that
269
+ // ends exactly on a chunk boundary reports eof only on the fill after it, so a
270
+ // check before the fill would refuse a backup of exactly maxChunks chunks —
271
+ // one the file path builds happily.
272
+ //
273
+ // Checked here rather than before the first byte because there is no length to
274
+ // count chunks from, and a stream cannot be re-cut: the way out is a bigger
275
+ // chunk on the next run, not a resume of this one.
276
+ if (count >= maxChunks) {
277
+ throw new Error(
278
+ `This command has already produced ${maxChunks} chunks of ` +
279
+ `${formatBytes(chunkSize)} and has not finished, which is as many as a ` +
280
+ 'backup holds. Run again with a larger --chunk-size.',
281
+ )
282
+ }
283
+
284
+ const fileName = chunkFileName(id, count)
285
+
286
+ // No bar until there is something to draw one for. A command that writes
287
+ // nothing would otherwise get a bar springing into existence at zero, only to
288
+ // be told a moment later that there is no backup to make.
289
+ if (progress === null) {
290
+ progress = createStreamProgress({ label: `Chunk ${count + 1}`, write: warn })
291
+ } else {
292
+ progress.setLabel(`Chunk ${count + 1}`)
293
+ }
294
+
295
+ // Offset 0 of a file holding exactly this chunk: uploadRange neither knows nor
296
+ // cares that the bytes arrived through a pipe rather than off a disk.
297
+ const { inputFile, sha256 } = await uploadRange(client, handle.fd, {
298
+ offset: 0,
299
+ length: filled.bytes,
300
+ fileName,
301
+ concurrency,
302
+ partSize,
303
+ onProgress: (bytes) => progress.advance(bytes),
304
+ retryOptions: { ...retryOptions, onRetry },
305
+ })
306
+
307
+ const message = await sendChunk(client, chat, {
308
+ inputFile,
309
+ fileName,
310
+ // A stream knows the number and not the count. The manifest card carries the
311
+ // total once there finally is one.
312
+ caption: chunkCaption({ id, number: count + 1, total: null }),
313
+ })
314
+
315
+ // Before the record is written, not after: a chunk is in the chat the instant
316
+ // sendChunk returns, and a saveState that throws must not be what hides it
317
+ // from the rollback that is about to run.
318
+ sent.push(message.id)
319
+
320
+ // Recorded the moment it lands, because from here on this record is the only
321
+ // list of what is in the chat under this id.
322
+ state = await markChunkDone(
323
+ key,
324
+ state,
325
+ count,
326
+ { msgId: message.id, size: filled.bytes, sha256 },
327
+ configDir,
328
+ )
329
+
330
+ size += filled.bytes
331
+ count += 1
332
+ }
333
+ } finally {
334
+ await discardChunkFile(handle, file, { writeErr, chunkSize })
335
+
336
+ // Unsaid whether the removal worked or not. If it did there is nothing left to
337
+ // remove; if it did not, `discardChunkFile` has already named the file on stderr,
338
+ // and a second attempt from the signal handler would say it twice.
339
+ onTempChunk(null)
340
+ }
341
+
342
+ if (eof) break
343
+ }
344
+ } finally {
345
+ // Same reason as runUpload: a send that fails must not leave "Error: ..." printed
346
+ // over the bar's own line.
347
+ progress?.finish()
348
+ }
349
+
350
+ // The stream's answer to the file path's re-stat, and the reason telstore spawns the
351
+ // command rather than reading a pipe. An EOF after a crash and an EOF after success are
352
+ // the same event on this end of the pipe; the exit code is the only thing that tells
353
+ // them apart, and a manifest sent without it would describe a truncated archive that
354
+ // restores perfectly and is garbage.
355
+ const { code, signal } = await child.exited
356
+ ended = true
357
+
358
+ if (code !== 0 || signal !== null) {
359
+ throw new Error(
360
+ signal !== null
361
+ ? `${childArgv[0]} was killed by ${signal} after writing ${formatBytes(size)}. ` +
362
+ 'telstore is not sending the manifest: what it wrote is an unfinished file.'
363
+ : `${childArgv[0]} exited ${code} after writing ${formatBytes(size)}. ` +
364
+ 'telstore is not sending the manifest: what it wrote is an unfinished file.',
365
+ )
366
+ }
367
+
368
+ // A clean exit having written nothing is usually a command whose arguments were wrong,
369
+ // and a backup of nothing is not a backup — restore would have nothing to write.
370
+ if (size === 0) {
371
+ throw new Error(`${childArgv[0]} wrote nothing, so there is no backup to make.`)
372
+ }
373
+
374
+ const manifest = buildManifest({
375
+ id,
376
+ name,
377
+ size,
378
+ chunkSize,
379
+ note,
380
+ chunks: Array.from({ length: count }, (_, i) => ({ i, ...state.done[String(i)] })),
381
+ })
382
+
383
+ const card = await sendManifest(client, chat, {
384
+ bytes: serializeManifest(manifest),
385
+ fileName: manifestFileName(id),
386
+ caption: manifestCaption({
387
+ id: manifest.id,
388
+ name: manifest.name,
389
+ size: manifest.size,
390
+ chunks: manifest.chunks.length,
391
+ createdAt: manifest.createdAt,
392
+ note: manifest.note ?? null,
393
+ }),
394
+ })
395
+
396
+ // The manifest is a message this run put in the chat like any other. Anything that
397
+ // fails after this line — the record write below, the closing line written into a pipe
398
+ // that has gone away — still rolls back, and a rollback that took the chunks but left
399
+ // this would leave a backup `list` advertises and `restore` cannot fulfil. Pushed last
400
+ // so it is removed last, the order `delete` keeps for the same reason: the manifest is
401
+ // the only index of the ids under it, so it is the one thing worth having if a removal
402
+ // stops halfway.
403
+ sent.push(card.id)
404
+
405
+ // And into the record, before that record can be left behind by a rollback that could
406
+ // not finish. `sent` is this process's memory and dies with it; the record is what
407
+ // `delete` reads afterwards, and the only other way it could find this card is
408
+ // `findManifestMessage`, which asks Telegram's text index — docs/design/captions.md
409
+ // records that index returning nothing for a channel whose documents were all plainly
410
+ // there, and nothing predicts when it happens. A delete that cannot find the manifest
411
+ // takes the chunks and leaves the card behind advertising a backup restore cannot
412
+ // fulfil. Written after the push, for the same reason the chunk ids are: a saveState
413
+ // that throws must not be what hides this message from the rollback about to run.
414
+ state = { ...state, manifestMsgId: card.id }
415
+ await saveState(key, state, configDir)
416
+
417
+ log(`\nDone. Restore with:\n npx telstore restore ${id}`)
418
+
419
+ // Cleared after the closing line rather than before it, which is what gives the write
420
+ // above anything to protect. Writing that line is a real thing that fails — `telstore …
421
+ // | head` closes the pipe under telstore's feet — and it rolls the run back. A record
422
+ // cleared a moment earlier would leave a rollback that Telegram then refuses with the
423
+ // chunks and the card still in the chat and nothing on this machine naming any of them.
424
+ await clearState(key, configDir)
425
+
426
+ return { id, chunks: count, size }
427
+ } finally {
428
+ // A run that fell over mid-chunk leaves the producer alive and blocked writing into a
429
+ // pipe nobody is reading. Killing it is not rollback — it is closing the door this
430
+ // function opened, and the door is more than the process: the abandoned iterator still
431
+ // holds stdout, so a child that outlives the signal goes on waiting on a pipe whose
432
+ // reader is never coming back. Returning the iterator releases it and destroys the
433
+ // stream, which turns that wait into an EPIPE the child can actually act on.
434
+ if (!ended) {
435
+ child.kill()
436
+ await reader.close()
437
+ }
438
+ }
439
+ } catch (err) {
440
+ // Whatever an abort surfaced as — a fill rejecting on a destroyed pipe, a producer that
441
+ // died on the signal, a stall that never came back — the reason this run stopped is the
442
+ // Ctrl-C, and saying so is what lets the caller leave with 130 instead of reporting a
443
+ // failure nobody had.
444
+ //
445
+ // Always throws: the error itself once the chat is clean again, or a report of the
446
+ // rollback that could not finish.
447
+ await rollback(aborted ? stopped() : err)
448
+ } finally {
449
+ // connect is inside the try now, because a rollback needs a live client and the finally
450
+ // that closes one has to run after it. So a connect that failed leaves nothing to close,
451
+ // and a warning about closing a client that never existed would bury the real reason the
452
+ // run stopped.
453
+ if (client) {
454
+ await closeQuietly(client, disconnect, (err) =>
455
+ warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`),
456
+ )
457
+ }
458
+ }
459
+ }
@@ -34,12 +34,40 @@ import {
34
34
  import { uploadRange } from '../uploader.js'
35
35
 
36
36
  // Above this threshold the wait must be spelled out, per spec §8.
37
- const LONG_WAIT_MS = 60_000
37
+ export const LONG_WAIT_MS = 60_000
38
38
 
39
39
  // A transient error that resolves itself on the next try is not news, and one line per
40
40
  // occurrence buries the progress bar in a wall of text. Stay quiet until the third retry:
41
41
  // by then the trouble has outlived two backoffs and is worth saying out loud.
42
- const ANNOUNCE_AFTER_ATTEMPT = 3
42
+ export const ANNOUNCE_AFTER_ATTEMPT = 3
43
+
44
+ // Retries and FLOOD_WAIT must be announced: a silent FLOOD_WAIT_3600 leaves the user
45
+ // staring at a frozen progress bar for an hour, assuming the process has hung.
46
+ //
47
+ // Exported because a stream upload waits on the same Telegram and has to say the same
48
+ // things about it. Two copies of this wording would drift, and the one that drifted would
49
+ // be the one nobody was reading at the time.
50
+ export function createOnRetry(warn) {
51
+ return function onRetry(err, attempt, delayMs, elapsedMs = 0) {
52
+ if (delayMs > LONG_WAIT_MS) {
53
+ warn(
54
+ `\nTelegram wants ${formatDuration(delayMs / 1000)} of waiting before the next send ` +
55
+ `(${err.message}). telstore is waiting and will carry on by itself, leave it running.\n`,
56
+ )
57
+ return
58
+ }
59
+
60
+ // The exception to staying quiet: an attempt that took a minute to fail spent that
61
+ // minute with the bar frozen, which is exactly what a hang looks like. Those are worth
62
+ // a line the first time, whatever the attempt number.
63
+ if (attempt < ANNOUNCE_AFTER_ATTEMPT && elapsedMs < LONG_WAIT_MS) return
64
+
65
+ warn(
66
+ `\nTemporary error (${err.message}), retry ${attempt} in ` +
67
+ `${formatDuration(delayMs / 1000)}.\n`,
68
+ )
69
+ }
70
+ }
43
71
 
44
72
  // Chunks and manifests differ only in where the bytes come from. Everything Telegram is
45
73
  // told about them — document, not preview; this exact file name — is decided once.
@@ -52,11 +80,15 @@ async function sendDocument(client, peer, { file, fileName, caption }) {
52
80
  })
53
81
  }
54
82
 
55
- async function realSendChunk(client, peer, { inputFile, fileName, caption }) {
83
+ // The defaults behind runUpload's `sendChunk` and `sendManifest` deps. Exported because a
84
+ // stream upload sends the same two kinds of document to the same Telegram, and a second copy
85
+ // of "document, not preview; this exact file name" is how the two start disagreeing about
86
+ // what telstore actually put in the chat.
87
+ export async function realSendChunk(client, peer, { inputFile, fileName, caption }) {
56
88
  return await sendDocument(client, peer, { file: inputFile, fileName, caption })
57
89
  }
58
90
 
59
- async function realSendManifest(client, peer, { bytes, fileName, caption }) {
91
+ export async function realSendManifest(client, peer, { bytes, fileName, caption }) {
60
92
  return await sendDocument(client, peer, {
61
93
  file: new CustomFile(fileName, bytes.length, '', bytes),
62
94
  fileName,
@@ -224,27 +256,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
224
256
  const log = silent ? () => {} : writeLog
225
257
  const warn = silent ? () => {} : writeErr
226
258
 
227
- // Retries and FLOOD_WAIT must be announced: a silent FLOOD_WAIT_3600 leaves the user
228
- // staring at a frozen progress bar for an hour, assuming the process has hung.
229
- function onRetry(err, attempt, delayMs, elapsedMs = 0) {
230
- if (delayMs > LONG_WAIT_MS) {
231
- warn(
232
- `\nTelegram wants ${formatDuration(delayMs / 1000)} of waiting before the next send ` +
233
- `(${err.message}). telstore is waiting and will carry on by itself, leave it running.\n`,
234
- )
235
- return
236
- }
237
-
238
- // The exception to staying quiet: an attempt that took a minute to fail spent that
239
- // minute with the bar frozen, which is exactly what a hang looks like. Those are worth
240
- // a line the first time, whatever the attempt number.
241
- if (attempt < ANNOUNCE_AFTER_ATTEMPT && elapsedMs < LONG_WAIT_MS) return
242
-
243
- warn(
244
- `\nTemporary error (${err.message}), retry ${attempt} in ` +
245
- `${formatDuration(delayMs / 1000)}.\n`,
246
- )
247
- }
259
+ const onRetry = createOnRetry(warn)
248
260
 
249
261
  log(`Backup ${state.id}`)
250
262
  log(`File ${absPath} (${formatBytes(stat.size)}, ${chunks.length} chunks)`)
@@ -11,7 +11,7 @@ import {
11
11
  } from '../client.js'
12
12
  import { configFile, defaultConfigDir, loadConfig } from '../config.js'
13
13
  import { chunkFileName, manifestFileName, parseManifest } from '../manifest.js'
14
- import { formatBytes, formatDuration } from '../progress.js'
14
+ import { formatBytes, formatDuration, plural } from '../progress.js'
15
15
  import { assertLoggedIn } from '../session.js'
16
16
  import { requireChat, resolveSettings } from '../settings.js'
17
17
 
@@ -21,10 +21,6 @@ function describeName(name) {
21
21
  return typeof name === 'string' && name.trim() !== '' ? name : '—'
22
22
  }
23
23
 
24
- function plural(n, word) {
25
- return `${n} ${word}${n === 1 ? '' : 's'}`
26
- }
27
-
28
24
  // What is wrong with one chunk, or null when nothing is. The first failing check wins: a
29
25
  // chunk is damaged or it is not, and listing three complaints about one message would make
30
26
  // "2 damaged" mean something other than two chunks.
package/src/manifest.js CHANGED
@@ -11,8 +11,60 @@ export function newBackupId(now = new Date(), randomHex = () => randomBytes(3).t
11
11
  return `telstore-${yyyy}${mm}${dd}-${randomHex()}`
12
12
  }
13
13
 
14
+ // The day a backup id carries, as the UTC second that day began. newBackupId stamps it above
15
+ // from the clock of the machine making the backup, so it is that machine's idea of the day
16
+ // rather than Telegram's — which is why the one reader of this (delete's walk of the chat)
17
+ // gives it a day of slack and only ever uses it as a floor.
18
+ //
19
+ // A date that does not exist is not a day: `telstore-20269999-abc` would otherwise roll over
20
+ // into a year's time and read as a floor above everything in the chat, which is an early stop
21
+ // nobody would see. Null instead, and the caller falls back to a bound it can prove.
22
+ const BACKUP_ID_DAY = /^telstore-(\d{4})(\d{2})(\d{2})-[0-9a-f]+$/
23
+
24
+ export function backupIdDay(id) {
25
+ const match = BACKUP_ID_DAY.exec(String(id))
26
+
27
+ if (!match) return null
28
+
29
+ const [year, month, day] = match.slice(1).map(Number)
30
+ const at = new Date(Date.UTC(year, month - 1, day))
31
+
32
+ if (at.getUTCFullYear() !== year || at.getUTCMonth() !== month - 1 || at.getUTCDate() !== day) {
33
+ return null
34
+ }
35
+
36
+ return Math.floor(at.getTime() / 1000)
37
+ }
38
+
39
+ // The infix in every chunk's file name. A constant rather than a literal for the same reason
40
+ // MANIFEST_SUFFIX is one: there are two readers of that name now — the writer below and
41
+ // isChunkFileName — and a reader that disagrees with the writer by one character finds
42
+ // nothing at all.
43
+ const CHUNK_INFIX = '.part'
44
+
14
45
  export function chunkFileName(id, i) {
15
- return `${id}.part${String(i + 1).padStart(4, '0')}`
46
+ return `${id}${CHUNK_INFIX}${String(i + 1).padStart(4, '0')}`
47
+ }
48
+
49
+ // Whether a document in a chat is a chunk of this backup, decided by the file name telstore
50
+ // itself wrote and not by the caption beside it — the rule findManifestMessage already keeps,
51
+ // for the same reason: a caption is text a person can edit and a file name is not.
52
+ //
53
+ // The number is checked but never read back. What the caller needs is which backup a document
54
+ // belongs to, and a chunk whose index says something impossible is still that backup's chunk.
55
+ // What the check is for is the other direction: without it `<id>.partial` or `<id>.part.bak`
56
+ // — names telstore never writes, but names a person can give a file they upload themselves —
57
+ // would be read as chunks of a backup and destroyed along with it.
58
+ export function isChunkFileName(id, fileName) {
59
+ if (typeof fileName !== 'string') return false
60
+
61
+ const prefix = `${id}${CHUNK_INFIX}`
62
+
63
+ if (!fileName.startsWith(prefix)) return false
64
+
65
+ const number = fileName.slice(prefix.length)
66
+
67
+ return number.length > 0 && /^[0-9]+$/.test(number)
16
68
  }
17
69
 
18
70
  // The suffix telstore has written on every manifest since version 1, and what `list` picks