telstore 0.1.8 → 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.
- package/README.md +110 -5
- package/bin/telstore.js +272 -4
- package/package.json +1 -1
- package/src/caption.js +6 -1
- package/src/cli.js +279 -12
- package/src/client.js +168 -9
- package/src/commands/delete.js +335 -43
- package/src/commands/down.js +311 -0
- package/src/commands/list.js +170 -16
- package/src/commands/restore-stream.js +407 -0
- package/src/commands/restore.js +27 -20
- package/src/commands/status.js +190 -19
- package/src/commands/upload-stream.js +459 -0
- package/src/commands/upload.js +37 -25
- package/src/commands/verify.js +294 -0
- package/src/manifest.js +59 -2
- package/src/progress.js +86 -0
- package/src/shell.js +33 -0
- package/src/spawn.js +38 -0
- package/src/state.js +78 -0
- package/src/stream.js +295 -0
- package/src/tar.js +23 -0
package/src/commands/delete.js
CHANGED
|
@@ -2,17 +2,24 @@ import { promises as fs } from 'node:fs'
|
|
|
2
2
|
|
|
3
3
|
import { chatName, describeChat } from '../chat.js'
|
|
4
4
|
import {
|
|
5
|
-
|
|
5
|
+
MESSAGE_BATCH_SIZE,
|
|
6
6
|
closeQuietly,
|
|
7
7
|
connect as realConnect,
|
|
8
8
|
deleteMessages as realDeleteMessages,
|
|
9
9
|
findManifestMessage,
|
|
10
|
+
iterDocuments,
|
|
10
11
|
readMessageBytes as realReadMessageBytes,
|
|
11
12
|
} from '../client.js'
|
|
12
13
|
import { askConfirm } from '../confirm.js'
|
|
13
14
|
import { configFile, defaultConfigDir, loadConfig } from '../config.js'
|
|
14
|
-
import {
|
|
15
|
-
|
|
15
|
+
import {
|
|
16
|
+
backupIdDay,
|
|
17
|
+
isChunkFileName,
|
|
18
|
+
manifestFileName,
|
|
19
|
+
manifestMessageIds,
|
|
20
|
+
parseManifestJson,
|
|
21
|
+
} from '../manifest.js'
|
|
22
|
+
import { createWalkNotice, formatBytes, formatDuration, plural } from '../progress.js'
|
|
16
23
|
import { assertLoggedIn } from '../session.js'
|
|
17
24
|
import { requireChat, resolveSettings } from '../settings.js'
|
|
18
25
|
import { clearRestore, clearState, findRestores, findStates } from '../state.js'
|
|
@@ -30,10 +37,6 @@ function describeSize(size) {
|
|
|
30
37
|
return Number.isSafeInteger(size) && size >= 0 ? formatBytes(size) : UNKNOWN
|
|
31
38
|
}
|
|
32
39
|
|
|
33
|
-
function plural(n, word) {
|
|
34
|
-
return `${n} ${word}${n === 1 ? '' : 's'}`
|
|
35
|
-
}
|
|
36
|
-
|
|
37
40
|
// The same rule the manifest gets, for the same reason: a message id is the name of
|
|
38
41
|
// something about to be destroyed for good, so a record that cannot say it exactly is
|
|
39
42
|
// refused whole rather than half-obeyed. Sorted by chunk index so the batches — and the
|
|
@@ -67,18 +70,183 @@ function stateMessageIds(record) {
|
|
|
67
70
|
})
|
|
68
71
|
}
|
|
69
72
|
|
|
73
|
+
// The manifest a stream upload left in the chat when its rollback could not finish.
|
|
74
|
+
// `searchManifest` is how delete normally finds a manifest, and it asks Telegram's text
|
|
75
|
+
// index — the one docs/design/captions.md records returning nothing for a channel whose
|
|
76
|
+
// documents were all plainly there, with nothing that predicts when that happens. A stream
|
|
77
|
+
// run writes the id of the card it sent into its record before that record can be left
|
|
78
|
+
// behind, so when the index comes up empty the record still names it. Without this, delete
|
|
79
|
+
// would take the chunks away and leave the manifest advertising a backup restore cannot
|
|
80
|
+
// fulfil, and nothing on this machine could ever find it again.
|
|
81
|
+
//
|
|
82
|
+
// Deliberately not folded into stateMessageIds: the manifest is the only index of the ids
|
|
83
|
+
// under it, so it goes last (docs/design/delete.md), and counting it among them would make
|
|
84
|
+
// the report say "2 chunk messages" for one chunk and a card.
|
|
85
|
+
function stateManifestId(record) {
|
|
86
|
+
const msgId = record.state.manifestMsgId
|
|
87
|
+
|
|
88
|
+
if (msgId === undefined || msgId === null) return null
|
|
89
|
+
|
|
90
|
+
// The same rule the chunk ids get, for the same reason: a message id names something about
|
|
91
|
+
// to be destroyed for good, so a record that cannot say it exactly is refused whole.
|
|
92
|
+
if (!Number.isSafeInteger(msgId) || msgId < 1) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`The record of unfinished backup ${record.state.id} gives ${JSON.stringify(msgId)} as ` +
|
|
95
|
+
`the message id of its manifest, which is not a message id. ${record.file} is ` +
|
|
96
|
+
'damaged, so telstore is not deleting anything.',
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return msgId
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// How far back the walk below reads before it stops without having proved it reached the
|
|
104
|
+
// start of the backup. It has to clear a whole backup and then some: MAX_CHUNKS chunks with
|
|
105
|
+
// the manifest over them is 10,001 documents of telstore's own, and a chat holds whatever
|
|
106
|
+
// else its owner put there in between. `list` has a ceiling for the same job and it is
|
|
107
|
+
// exactly 10,000, which would stop this walk one document short of the largest backup
|
|
108
|
+
// telstore makes — so this is its own number rather than that one borrowed.
|
|
109
|
+
export const MAX_DELETE_DOCUMENTS = 20000
|
|
110
|
+
|
|
111
|
+
// The slack under the day the backup id carries, and it is subtracted rather than added: the
|
|
112
|
+
// floor has to sit *below* everything this backup could have sent, and a floor one day too
|
|
113
|
+
// high stops the walk early, sets `complete`, and prints "Done" over documents nobody read.
|
|
114
|
+
// That day comes from the clock of the machine that made the backup and a document's date
|
|
115
|
+
// comes from Telegram's, and the two need not agree; a day is far more than a skew anybody
|
|
116
|
+
// would leave unnoticed, since a machine a day out dates every backup wrongly in `list`. The
|
|
117
|
+
// cost is one extra day of documents read.
|
|
118
|
+
const DAY_SECONDS = 86400
|
|
119
|
+
|
|
120
|
+
// What the manifest and the local record between them cannot promise: everything of this
|
|
121
|
+
// backup that is actually in the chat. Measured 2026-09-09 against a real account, a stream
|
|
122
|
+
// upload left by a second Ctrl-C put a chunk in the chat that its own record never named
|
|
123
|
+
// (docs/design/data-integrity.md), and the `delete` that run printed then reported the backup
|
|
124
|
+
// removed with 12MB of it still sitting there. Every chunk carries the backup id in the file
|
|
125
|
+
// name telstore wrote, so the chat can be asked instead of taken on trust.
|
|
126
|
+
//
|
|
127
|
+
// Newest first, and it stops only where *every* floor it has agrees that there is nothing of
|
|
128
|
+
// this backup further down. There are two, and neither is trusted to be right on its own:
|
|
129
|
+
//
|
|
130
|
+
// - The oldest message id the backup is known to have sent. telstore sends chunk 0 first
|
|
131
|
+
// and records each id as it lands, so the ids it knows are a prefix of the ids it sent
|
|
132
|
+
// and the smallest is the backup's first message. That argument holds for records and
|
|
133
|
+
// manifests telstore wrote; it does not hold for the hand-edited ones both of them are,
|
|
134
|
+
// and a record with chunk 0 taken out of it raises this floor over chunks that are
|
|
135
|
+
// really there.
|
|
136
|
+
// - The day the backup id carries, less a day. Derived from the id the user typed rather
|
|
137
|
+
// than from any file, so a doctored record cannot move it — but it is the uploading
|
|
138
|
+
// machine's clock against Telegram's, which is the reason for the slack.
|
|
139
|
+
//
|
|
140
|
+
// Requiring both is what makes each one's blind spot somebody else's problem: an id floor
|
|
141
|
+
// lifted by an edited record is held down by the date, and a date floor lifted by a wrong
|
|
142
|
+
// clock is held down by the id. It costs one extra day of documents, and the alternative is
|
|
143
|
+
// a walk that stops early and then says "Done", which is the failure this exists to remove.
|
|
144
|
+
// Where only one floor exists it decides alone, and where neither does the budget is all
|
|
145
|
+
// there is.
|
|
146
|
+
//
|
|
147
|
+
// Chunks found do not lower the floor. It is tempting, and it is how a walk with no floor at
|
|
148
|
+
// all quietly stops early: a chunk deleted by hand out of the middle breaks the chain, and
|
|
149
|
+
// the next document down is below the last one found rather than above it.
|
|
150
|
+
async function findChunksInChat(client, chat, backupId, options) {
|
|
151
|
+
const {
|
|
152
|
+
known,
|
|
153
|
+
readDocuments,
|
|
154
|
+
retryOptions,
|
|
155
|
+
offsetId,
|
|
156
|
+
max = MAX_DELETE_DOCUMENTS,
|
|
157
|
+
onRead,
|
|
158
|
+
} = options
|
|
159
|
+
|
|
160
|
+
let floorId = null
|
|
161
|
+
|
|
162
|
+
for (const id of known) floorId = floorId === null ? id : Math.min(floorId, id)
|
|
163
|
+
|
|
164
|
+
const day = backupIdDay(backupId)
|
|
165
|
+
const floorDate = day === null ? null : day - DAY_SECONDS
|
|
166
|
+
|
|
167
|
+
const floors = []
|
|
168
|
+
|
|
169
|
+
if (floorId !== null) floors.push((document) => document.id <= floorId)
|
|
170
|
+
if (floorDate !== null) floors.push((document) => document.date < floorDate)
|
|
171
|
+
|
|
172
|
+
const chunks = []
|
|
173
|
+
let manifest = null
|
|
174
|
+
let read = 0
|
|
175
|
+
let reachedFloor = false
|
|
176
|
+
|
|
177
|
+
for await (const document of readDocuments(client, chat, { max, offsetId, retryOptions })) {
|
|
178
|
+
read += 1
|
|
179
|
+
onRead?.(read, chunks.length)
|
|
180
|
+
|
|
181
|
+
// Every chunk of this backup, not only the ones nothing names yet. Which of them are
|
|
182
|
+
// leftovers is not a question this loop can answer: the card that names them may still
|
|
183
|
+
// be several documents below, and the caller works it out once it has read that.
|
|
184
|
+
if (isChunkFileName(backupId, document.fileName)) {
|
|
185
|
+
chunks.push(document.id)
|
|
186
|
+
} else if (manifest === null && document.fileName === manifestFileName(backupId)) {
|
|
187
|
+
// The same rule findManifestMessage keeps, reached without the text index: a document
|
|
188
|
+
// named <id>.manifest.json is this backup's card. Only used when the search came back
|
|
189
|
+
// with nothing, and docs/design/captions.md is the record of how often that happens.
|
|
190
|
+
//
|
|
191
|
+
// The raw message, not the flat document around it — the same thing findManifestMessage
|
|
192
|
+
// hands back, because both of them feed readMessageBytes and teleproto's downloadMedia
|
|
193
|
+
// takes an Api.Message or treats its argument as media and throws "Cannot download media
|
|
194
|
+
// of type object". One shape for one job, so the next reader cannot pick the wrong half.
|
|
195
|
+
manifest = document.message
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (floors.length > 0 && floors.every((below) => below(document))) {
|
|
199
|
+
reachedFloor = true
|
|
200
|
+
break
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Two other ways a walk ends knowing it saw everything: it reached a floor, or the chat ran
|
|
205
|
+
// out of documents before the budget did. A walk stopped by the budget alone is the only
|
|
206
|
+
// one that cannot say what is behind it, and this is the flag that stops the report saying
|
|
207
|
+
// "Done" over a removal nothing proved was complete.
|
|
208
|
+
return { chunks, manifest, read, complete: reachedFloor || read < max }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// A file record is keyed on a path; a stream record has none, because its bytes came from a
|
|
212
|
+
// command's stdout, and carries the name the backup was given instead. Reading only the path
|
|
213
|
+
// describes a backup whose name is sitting right there in the record as the placeholder for
|
|
214
|
+
// something nothing could say.
|
|
215
|
+
function describeRecord(state) {
|
|
216
|
+
return describeName(state?.name ?? state?.path)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// One sentence for one fact, said once before the question and once in the report. Neither
|
|
220
|
+
// alarm nor a footnote: a chunk in the chat that nothing on this machine names is exactly
|
|
221
|
+
// what this walk was added to find, and reading the chat is how it was found.
|
|
222
|
+
function strayChunks(count) {
|
|
223
|
+
return (
|
|
224
|
+
`${plural(count, 'chunk message')} of this backup that no manifest and no record on ` +
|
|
225
|
+
'this machine names'
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
70
229
|
export async function runDelete(backupId, options = {}, deps = {}) {
|
|
71
230
|
const {
|
|
72
231
|
connect = realConnect,
|
|
73
232
|
disconnect = (client) => client.destroy(),
|
|
74
233
|
configDir = defaultConfigDir(),
|
|
75
234
|
searchManifest = findManifestMessage,
|
|
235
|
+
readDocuments = iterDocuments,
|
|
236
|
+
// Through deps rather than read straight off the constant, for the reason runStreamUpload
|
|
237
|
+
// takes maxChunks that way: a ceiling twenty thousand documents up is a ceiling no test
|
|
238
|
+
// will ever reach, and the wording it changes is the one that must not say "Done".
|
|
239
|
+
maxDocuments = MAX_DELETE_DOCUMENTS,
|
|
76
240
|
readMessageBytes = realReadMessageBytes,
|
|
77
241
|
deleteMessages = realDeleteMessages,
|
|
78
242
|
confirm = askConfirm,
|
|
79
243
|
retryOptions = {},
|
|
80
244
|
writeErr = (line) => process.stderr.write(line),
|
|
81
245
|
log: writeLog = (line) => console.log(line),
|
|
246
|
+
// The walk's notice goes to stderr and only onto a terminal, the same as `list`'s: a
|
|
247
|
+
// carriage return in a log file is rubbish, and delete is a command scripts run.
|
|
248
|
+
writeProgress = process.stderr.isTTY ? (text) => process.stderr.write(text) : null,
|
|
249
|
+
now = () => Date.now(),
|
|
82
250
|
silent = false,
|
|
83
251
|
} = deps
|
|
84
252
|
|
|
@@ -117,37 +285,37 @@ export async function runDelete(backupId, options = {}, deps = {}) {
|
|
|
117
285
|
const client = await connect(config, { verbose: settings.verbose })
|
|
118
286
|
|
|
119
287
|
try {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
if (!manifestMessage && !record) {
|
|
123
|
-
throw new Error(
|
|
124
|
-
`No backup ${backupId} found in ${chatName(chat)}, and no unfinished record of it on ` +
|
|
125
|
-
'this machine. Check the id with "npx telstore list", or use --chat to point at the ' +
|
|
126
|
-
'right chat.',
|
|
127
|
-
)
|
|
128
|
-
}
|
|
288
|
+
const searched = await searchManifest(client, chat, backupId)
|
|
129
289
|
|
|
290
|
+
let manifestMessage = searched
|
|
130
291
|
let manifest = null
|
|
131
292
|
const ids = new Set()
|
|
132
293
|
|
|
133
|
-
|
|
134
|
-
|
|
294
|
+
// Reading one message's manifest into the set of ids about to be destroyed. Two callers
|
|
295
|
+
// now: the card Telegram's search index handed over, and the card the walk below met on
|
|
296
|
+
// its way down when that index had nothing to say.
|
|
297
|
+
async function readManifest(message) {
|
|
298
|
+
const parsed = parseManifestJson(await readMessageBytes(client, message))
|
|
135
299
|
|
|
136
300
|
// The manifest was found by the file name telstore itself wrote, and that name is the
|
|
137
301
|
// id this command was asked about. A body naming a different backup is a file that was
|
|
138
302
|
// renamed or replaced, and its message ids point at somebody else's chunks — the one
|
|
139
303
|
// mistake in this whole command that nothing can undo.
|
|
140
|
-
if (
|
|
304
|
+
if (parsed?.id !== undefined && parsed.id !== backupId) {
|
|
141
305
|
throw new Error(
|
|
142
306
|
`The manifest named ${manifestFileName(backupId)} describes backup ` +
|
|
143
|
-
`${JSON.stringify(
|
|
307
|
+
`${JSON.stringify(parsed.id)}, not ${backupId}. Its message ids point at ` +
|
|
144
308
|
'another backup\'s chunks, so telstore is not deleting anything.',
|
|
145
309
|
)
|
|
146
310
|
}
|
|
147
311
|
|
|
148
|
-
for (const id of manifestMessageIds(
|
|
312
|
+
for (const id of manifestMessageIds(parsed)) ids.add(id)
|
|
313
|
+
|
|
314
|
+
return parsed
|
|
149
315
|
}
|
|
150
316
|
|
|
317
|
+
if (manifestMessage) manifest = await readManifest(manifestMessage)
|
|
318
|
+
|
|
151
319
|
// Both sources describe the same backup, so an id in either is a message this backup put
|
|
152
320
|
// in the chat. In practice the record holds nothing the manifest does not — but it is a
|
|
153
321
|
// file on disk that a truncated write or a hand edit can mangle, and an id left out here
|
|
@@ -156,7 +324,64 @@ export async function runDelete(backupId, options = {}, deps = {}) {
|
|
|
156
324
|
for (const id of stateMessageIds(record)) ids.add(id)
|
|
157
325
|
}
|
|
158
326
|
|
|
159
|
-
|
|
327
|
+
// And now the chat's own answer, because neither of those two is a list of what is there.
|
|
328
|
+
// The walk starts under the card when the search found one — a backup's manifest is the
|
|
329
|
+
// last message its run sends, so nothing of it is newer, and everything posted since is
|
|
330
|
+
// read for nothing. With no card it starts at the newest message in the chat, which is
|
|
331
|
+
// exactly where the chunk a "leave now" left behind will be.
|
|
332
|
+
const notice =
|
|
333
|
+
writeProgress && !silent ? createWalkNotice({ write: writeProgress, now }) : null
|
|
334
|
+
|
|
335
|
+
let walk
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
walk = await findChunksInChat(client, chat, backupId, {
|
|
339
|
+
known: ids,
|
|
340
|
+
readDocuments,
|
|
341
|
+
retryOptions,
|
|
342
|
+
offsetId: searched?.id ?? 0,
|
|
343
|
+
max: maxDocuments,
|
|
344
|
+
onRead: (read, seen) =>
|
|
345
|
+
notice?.tick(
|
|
346
|
+
`Reading ${chatName(chat)}… ${read} documents, ` +
|
|
347
|
+
`${plural(seen, 'chunk')} of this backup`,
|
|
348
|
+
),
|
|
349
|
+
})
|
|
350
|
+
} finally {
|
|
351
|
+
notice?.clear()
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (!manifestMessage && walk.manifest) {
|
|
355
|
+
manifestMessage = walk.manifest
|
|
356
|
+
manifest = await readManifest(manifestMessage)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Counted after the walk's own manifest has had its say, or a card the search missed
|
|
360
|
+
// would have every one of its chunks reported as a message nothing names.
|
|
361
|
+
const strays = walk.chunks.filter((id) => !ids.has(id))
|
|
362
|
+
|
|
363
|
+
for (const id of strays) ids.add(id)
|
|
364
|
+
|
|
365
|
+
if (!manifestMessage && !record && ids.size === 0) {
|
|
366
|
+
throw new Error(
|
|
367
|
+
`No backup ${backupId} found in ${chatName(chat)}` +
|
|
368
|
+
(walk.complete ? '' : ` — the newest ${walk.read} documents were read`) +
|
|
369
|
+
', and no unfinished record of it on this machine. Check the id with ' +
|
|
370
|
+
'"npx telstore list", or use --chat to point at the right chat.',
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Sorted for the reason stateMessageIds sorts by chunk index, now that a third source
|
|
375
|
+
// feeds this set: a message id climbs with the chunk it carries, so ascending is the
|
|
376
|
+
// order this backup was sent in and the order the report's "removed 3 of 5" counts in.
|
|
377
|
+
// Insertion order is not that — the walk hands its chunks over newest first, and a stray
|
|
378
|
+
// older than everything the record names would otherwise go out in the middle.
|
|
379
|
+
const chunkIds = [...ids].sort((a, b) => a - b)
|
|
380
|
+
|
|
381
|
+
// Where this backup's manifest is, if anywhere. The chat's own answer wins over the
|
|
382
|
+
// record's, the way it does for the chunk ids above: the record is a file on disk that a
|
|
383
|
+
// hand edit can mangle, and the message the search returned is one telstore just looked at.
|
|
384
|
+
const manifestId = manifestMessage?.id ?? (record ? stateManifestId(record) : null)
|
|
160
385
|
|
|
161
386
|
if (manifest) {
|
|
162
387
|
log(`Backup ${backupId}`)
|
|
@@ -165,23 +390,42 @@ export async function runDelete(backupId, options = {}, deps = {}) {
|
|
|
165
390
|
`(${describeSize(manifest.size)}, ${plural(manifest.chunks.length, 'chunk')})`,
|
|
166
391
|
)
|
|
167
392
|
} else {
|
|
168
|
-
|
|
169
|
-
|
|
393
|
+
// "no manifest in the chat" is a claim, and the record can contradict it: a search that
|
|
394
|
+
// returned nothing is not the same fact as a manifest that was never sent.
|
|
395
|
+
log(
|
|
396
|
+
`Backup ${backupId} (unfinished — ` +
|
|
397
|
+
(manifestId === null
|
|
398
|
+
? 'no manifest in the chat)'
|
|
399
|
+
: 'its record names a manifest the chat search did not return)'),
|
|
400
|
+
)
|
|
401
|
+
log(`File ${record ? describeRecord(record.state) : UNKNOWN}`)
|
|
170
402
|
}
|
|
171
403
|
|
|
172
404
|
log(`From ${describeChat(chat)}`)
|
|
405
|
+
|
|
406
|
+
// Said before the question that authorises the removal, not only after it: the count in
|
|
407
|
+
// that question already includes these, and a number a person is agreeing to has to be
|
|
408
|
+
// one they can account for.
|
|
409
|
+
if (strays.length > 0) {
|
|
410
|
+
log(`Also ${strayChunks(strays.length)}, found by reading ${chatName(chat)}`)
|
|
411
|
+
}
|
|
412
|
+
|
|
173
413
|
log('')
|
|
174
414
|
|
|
175
415
|
const prompt = manifest
|
|
176
416
|
? `Delete this backup from ${chatName(chat)}? The chunks cannot be recovered. [y/N] `
|
|
177
|
-
: `Delete the ${plural(chunkIds.length, 'chunk message')} it sent
|
|
178
|
-
|
|
417
|
+
: `Delete the ${plural(chunkIds.length, 'chunk message')} it sent` +
|
|
418
|
+
`${manifestId === null ? '' : ', the manifest it named'}` +
|
|
419
|
+
// Only when there is one. Nothing on this machine names the chunks a walk found on
|
|
420
|
+
// its own, and a question that offers to drop a record that does not exist is one
|
|
421
|
+
// whose answer means something other than what it says.
|
|
422
|
+
`${record ? ', and its local record' : ''}? The chunks cannot be recovered. [y/N] `
|
|
179
423
|
|
|
180
424
|
if (!options.yes && !(await confirm(prompt))) {
|
|
181
425
|
throw new Error('Cancelled on request.')
|
|
182
426
|
}
|
|
183
427
|
|
|
184
|
-
const loud = chunkIds.length >
|
|
428
|
+
const loud = chunkIds.length > MESSAGE_BATCH_SIZE
|
|
185
429
|
let removed = 0
|
|
186
430
|
|
|
187
431
|
try {
|
|
@@ -196,11 +440,18 @@ export async function runDelete(backupId, options = {}, deps = {}) {
|
|
|
196
440
|
throw new Error(
|
|
197
441
|
`Removed ${removed} of ${plural(chunkIds.length, 'chunk message')} of ${backupId}, ` +
|
|
198
442
|
`then Telegram refused: ${err.message}. ` +
|
|
443
|
+
// What is still standing that can name the rest, and nothing else. Where the walk
|
|
444
|
+
// of the chat is the only thing that found these there is no such list on this
|
|
445
|
+
// machine at all, and saying a record was kept when there is none sends somebody
|
|
446
|
+
// looking through ~/.telstore for a file that was never written.
|
|
199
447
|
(manifestMessage
|
|
200
448
|
? 'The manifest was left in place on purpose — it is the only list of the ' +
|
|
201
449
|
'messages that are still there. '
|
|
202
|
-
:
|
|
203
|
-
'
|
|
450
|
+
: record
|
|
451
|
+
? 'The local record was left in place on purpose — it is the only list of the ' +
|
|
452
|
+
'messages that are still there. '
|
|
453
|
+
: `Nothing on this machine names the rest: reading ${chatName(chat)} for ` +
|
|
454
|
+
`${backupId} is what found them, which is what running this again does. `) +
|
|
204
455
|
'Run the same command again to finish.',
|
|
205
456
|
)
|
|
206
457
|
}
|
|
@@ -210,9 +461,9 @@ export async function runDelete(backupId, options = {}, deps = {}) {
|
|
|
210
461
|
// Only now. The manifest is the only index of the ids above, and where there is no
|
|
211
462
|
// manifest the local record is. Anything that throws before this line leaves the way
|
|
212
463
|
// back intact, and running delete again picks up where this run stopped.
|
|
213
|
-
if (
|
|
464
|
+
if (manifestId !== null) {
|
|
214
465
|
try {
|
|
215
|
-
await deleteMessages(client, chat, [
|
|
466
|
+
await deleteMessages(client, chat, [manifestId], {
|
|
216
467
|
retryOptions: { ...retryOptions, onRetry },
|
|
217
468
|
})
|
|
218
469
|
} catch (err) {
|
|
@@ -249,17 +500,42 @@ export async function runDelete(backupId, options = {}, deps = {}) {
|
|
|
249
500
|
}
|
|
250
501
|
}
|
|
251
502
|
|
|
252
|
-
|
|
503
|
+
// "Done" is a claim that there is nothing of this backup left, and only a walk that
|
|
504
|
+
// reached a floor it can prove has earned it. One stopped by its own budget removed
|
|
505
|
+
// everything it found and cannot say what is behind it, so it says that instead.
|
|
506
|
+
const lead = walk.complete ? 'Done. Removed' : 'Removed'
|
|
507
|
+
|
|
508
|
+
if (manifestId !== null) {
|
|
253
509
|
log(
|
|
254
|
-
`\
|
|
510
|
+
`\n${lead} ${backupId} from ${chatName(chat)}: ` +
|
|
255
511
|
`${plural(chunkIds.length, 'chunk message')} and its manifest.`,
|
|
256
512
|
)
|
|
257
513
|
if (record) log('The local record of this backup was removed too.')
|
|
258
|
-
} else {
|
|
514
|
+
} else if (record) {
|
|
259
515
|
log(
|
|
260
|
-
`\
|
|
516
|
+
`\n${lead} ${plural(chunkIds.length, 'chunk message')} from ${chatName(chat)} ` +
|
|
261
517
|
`and dropped the local record of ${backupId}.`,
|
|
262
518
|
)
|
|
519
|
+
} else {
|
|
520
|
+
// Nothing on this machine ever named these: the chat is where they were found and the
|
|
521
|
+
// chat is all there was to drop.
|
|
522
|
+
log(
|
|
523
|
+
`\n${lead} ${plural(chunkIds.length, 'chunk message')} of ${backupId} from ` +
|
|
524
|
+
`${chatName(chat)}.`,
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (strays.length > 0) {
|
|
529
|
+
log(`That includes ${strayChunks(strays.length)}, found by reading ${chatName(chat)}.`)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (!walk.complete) {
|
|
533
|
+
log(
|
|
534
|
+
`telstore read the newest ${walk.read} documents of ${chatName(chat)} without ` +
|
|
535
|
+
'reaching the start of this backup, so it cannot say that was all of it. Anything ' +
|
|
536
|
+
`of ${backupId} still there carries that id in its file name, which is what ` +
|
|
537
|
+
"Telegram's own search reads.",
|
|
538
|
+
)
|
|
263
539
|
}
|
|
264
540
|
|
|
265
541
|
for (const partial of stranded) {
|
|
@@ -272,8 +548,13 @@ export async function runDelete(backupId, options = {}, deps = {}) {
|
|
|
272
548
|
return {
|
|
273
549
|
id: backupId,
|
|
274
550
|
chunks: chunkIds.length,
|
|
275
|
-
manifestDeleted:
|
|
551
|
+
manifestDeleted: manifestId !== null,
|
|
276
552
|
stateCleared: Boolean(record),
|
|
553
|
+
// What the walk of the chat added, and whether it got far enough to say that was all
|
|
554
|
+
// of it. A batch prints one line per id and these are the two things that line would
|
|
555
|
+
// otherwise leave out.
|
|
556
|
+
strays: strays.length,
|
|
557
|
+
complete: walk.complete,
|
|
277
558
|
}
|
|
278
559
|
} finally {
|
|
279
560
|
await closeQuietly(client, disconnect, (err) =>
|
|
@@ -368,9 +649,16 @@ export async function runDeletes(backupIds, options = {}, deps = {}) {
|
|
|
368
649
|
}
|
|
369
650
|
|
|
370
651
|
if (unknown.length > 0) {
|
|
652
|
+
// "No manifest for it", not "not found": a single delete walks the chat for chunks
|
|
653
|
+
// carrying the id, and finds them where nothing on this machine names them. A batch does
|
|
654
|
+
// not, because this question is asked about every id at once and before anything is
|
|
655
|
+
// destroyed, and a walk apiece would turn one mistyped id into minutes of reading
|
|
656
|
+
// somebody's archive. Saying which is which is what keeps the sentence true.
|
|
371
657
|
throw new Error(
|
|
372
|
-
`Nothing was deleted: ${unknown.join(', ')} —
|
|
373
|
-
|
|
658
|
+
`Nothing was deleted: ${unknown.join(', ')} — no manifest for it in ` +
|
|
659
|
+
`${chatName(chat)}, and no local record of it on this machine either. Check the ids ` +
|
|
660
|
+
'with "npx telstore list". Deleting one id on its own also reads the chat for ' +
|
|
661
|
+
'chunks nothing names; a batch does not.',
|
|
374
662
|
)
|
|
375
663
|
}
|
|
376
664
|
|
|
@@ -429,7 +717,7 @@ function listingLines(rows, chat) {
|
|
|
429
717
|
id,
|
|
430
718
|
name: manifest
|
|
431
719
|
? describeName(manifest.name)
|
|
432
|
-
: `${
|
|
720
|
+
: `${describeRecord(record?.state)} (unfinished)`,
|
|
433
721
|
size: manifest ? describeSize(manifest.size) : UNKNOWN,
|
|
434
722
|
chunks: plural(countChunks(manifest, record), 'chunk'),
|
|
435
723
|
}))
|
|
@@ -469,10 +757,14 @@ function summaryLines(results, failed) {
|
|
|
469
757
|
...results.map((result) => {
|
|
470
758
|
const id = result.id.padEnd(width)
|
|
471
759
|
|
|
472
|
-
return result.error
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
760
|
+
if (result.error) return ` ${id} failed: ${result.error}`
|
|
761
|
+
|
|
762
|
+
return (
|
|
763
|
+
` ${id} ${plural(result.chunks, 'chunk message')} removed` +
|
|
764
|
+
(result.manifestDeleted ? ' with its manifest' : '') +
|
|
765
|
+
(result.strays > 0 ? `, ${result.strays} of them named by nothing on this machine` : '') +
|
|
766
|
+
(result.complete ? '' : ' — telstore could not read far enough back to say that was all')
|
|
767
|
+
)
|
|
476
768
|
}),
|
|
477
769
|
]
|
|
478
770
|
}
|