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 CHANGED
@@ -28,8 +28,9 @@ npx telstore restore telstore-20260905-7f3a91
28
28
  | `telstore <file\|folder\|pattern>...` | Split each file and upload it. Prints the `backupId` you restore with. |
29
29
  | `telstore list` | The backups stored in the destination, newest first. |
30
30
  | `telstore restore <backup-id>...` | Download every chunk and reassemble the file. Several ids run one after another. |
31
+ | `telstore verify <backup-id>...` | Check that every chunk of a backup is still in the chat. Downloads nothing. |
31
32
  | `telstore delete <backup-id>...` | Remove a backup's chunks and manifest from the chat, for good. Several ids are listed and confirmed once. |
32
- | `telstore status` | Account, destination, and unfinished backups. |
33
+ | `telstore status` | Account, destination, and unfinished uploads and restores. |
33
34
  | `telstore config` | Show or change settings. |
34
35
  | `telstore token` | Print a session token for a machine you do not trust. |
35
36
  | `telstore logout` | Remove the locally stored session. |
@@ -65,7 +66,7 @@ a prompt that does not echo.
65
66
 
66
67
  Every chunk goes up as a document captioned `๐Ÿ“ฆ <backupId> ยท 3/12`, followed by a manifest
67
68
  carrying a summary card โ€” file name, size, id, date and the restore command. `list` reads
68
- those cards straight out of the chat, one search and no downloads:
69
+ those cards straight out of the chat, no downloads:
69
70
 
70
71
  ```
71
72
  Destination https://web.telegram.org/k/#@my_backups
@@ -117,13 +118,44 @@ it, and the run ends with a line per file and a non-zero exit code:
117
118
  c.tar telstore-20260905-9de447 (1 chunk)
118
119
  ```
119
120
 
121
+ ## Checking a backup is still there
122
+
123
+ A backup is a set of messages in a chat, and messages can be deleted by hand. `list` reads
124
+ the manifest's card and would happily show a backup whose chunks are long gone; `verify` asks
125
+ the chat about every chunk the manifest names:
126
+
127
+ ```
128
+ $ npx telstore verify telstore-20260905-7f3a91
129
+ Backup telstore-20260905-7f3a91
130
+ File data.tar (21.4 GB, 12 chunks)
131
+ In https://web.telegram.org/k/#@my_backups
132
+
133
+ 12 chunks present, at the sizes the manifest records.
134
+ This does not download them, so it cannot prove their contents.
135
+ ```
136
+
137
+ It costs one request per hundred chunks and no bandwidth, so it is cheap enough to run on a
138
+ schedule. What it proves is that a restore would find everything it needs โ€” every chunk still
139
+ there, under the file name telstore wrote, at the length the manifest records. It does not
140
+ read the chunks, so it cannot speak for what is inside them; only a restore does that, and a
141
+ restore checks every sha256 before it renames anything into place.
142
+
143
+ A backup missing chunks is named line by line, and the exit code is 1:
144
+
145
+ ```
146
+ Chunk 3/12 is gone: message 1042 is no longer in @my_backups.
147
+
148
+ 12 chunks checked, 1 damaged. This backup cannot be restored.
149
+ ```
150
+
120
151
  ## Several backups at once
121
152
 
122
- `restore` and `delete` take a list of ids the same way, over one connection, with a summary
123
- and a non-zero exit code if any of them failed:
153
+ `restore`, `verify` and `delete` take a list of ids the same way, over one connection, with a
154
+ summary and a non-zero exit code if any of them failed:
124
155
 
125
156
  ```bash
126
157
  npx telstore restore telstore-20260905-7f3a91 telstore-20260901-9de447
158
+ npx telstore verify telstore-20260905-7f3a91 telstore-20260901-9de447
127
159
  npx telstore delete telstore-20260905-7f3a91 telstore-20260901-9de447
128
160
  ```
129
161
 
@@ -143,7 +175,14 @@ After a batch, run telstore again with **only the files that are left**: the fin
143
175
  have had their records cleared, so repeating the whole command would upload them a second
144
176
  time as new backups. `npx telstore status` lists what is unfinished.
145
177
 
146
- **Restore keeps no state** โ€” `Ctrl-C` mid-restore saves nothing, running again starts over.
178
+ ## Resuming a restore
179
+
180
+ `Ctrl-C` mid-restore keeps the `<target>.partial` file rather than throwing it away. Running
181
+ the same command again hashes each chunk-sized region of it against the manifest, in order,
182
+ and carries on from the first one that does not match โ€” nothing already on disk is trusted
183
+ just because it is there. `npx telstore status` lists unfinished restores alongside
184
+ unfinished uploads, with a resume command for each.
185
+
147
186
  And `delete` has **no undo**: Telegram is the only copy.
148
187
 
149
188
  ## Running on a machine you do not trust
@@ -173,7 +212,8 @@ There is no expiry and no revocation: to end a session for good, terminate it un
173
212
  protects your login rather than your files.
174
213
  - A chunk cannot exceed 1950MB: Telegram accepts at most 4000 parts of 512KB per file.
175
214
  - Deleting a chunk message in the Telegram app destroys the backup, and keeping the
176
- `backupId` is what saves you hunting for its manifest in the chat by hand.
215
+ `backupId` is what saves you hunting for its manifest in the chat by hand. `verify` is how
216
+ you find that out before you need the file rather than after.
177
217
 
178
218
  Settings and credentials live in `~/.telstore/config.json`, mode 600 โ€” `apiId`, `apiHash` and
179
219
  the session at the top level (or a single `sealed` blob after `login --token`), everything
package/bin/telstore.js CHANGED
@@ -14,9 +14,9 @@ const SIGINT_EXIT_CODE = 130
14
14
  let currentCommand = null
15
15
  let currentBackupId = null
16
16
 
17
- // A batch clears each finished file's record as it goes, so by the time Ctrl-C lands these
18
- // are backups no second run should touch. Ctrl-C needs their names to say so.
19
- const finishedUploads = []
17
+ // A batch clears each finished item's record as it goes, so by the time Ctrl-C lands these
18
+ // are transfers no second run should touch. Ctrl-C needs their names to say so.
19
+ const finished = []
20
20
 
21
21
  process.on('SIGINT', () => {
22
22
  // A passphrase prompt has stdin in raw mode, and process.exit skips readline's own cleanup.
@@ -24,7 +24,7 @@ process.on('SIGINT', () => {
24
24
  if (process.stdin.isTTY) process.stdin.setRawMode(false)
25
25
 
26
26
  process.stderr.write(
27
- interruptMessage(currentCommand, { backupId: currentBackupId, done: finishedUploads }),
27
+ interruptMessage(currentCommand, { backupId: currentBackupId, done: finished }),
28
28
  )
29
29
  process.exit(SIGINT_EXIT_CODE)
30
30
  })
@@ -104,7 +104,7 @@ async function main() {
104
104
  currentBackupId = id
105
105
  },
106
106
  onFileDone: (file) => {
107
- if (file.id) finishedUploads.push(file)
107
+ if (file.id) finished.push(file)
108
108
  },
109
109
  })
110
110
 
@@ -121,8 +121,30 @@ async function main() {
121
121
 
122
122
  const { runRestores } = await import('../src/commands/restore.js')
123
123
 
124
- const { failed } = await runRestores(parsed.args, parsed.options)
124
+ const { failed } = await runRestores(parsed.args, parsed.options, {
125
+ onBackupId: (id) => {
126
+ currentBackupId = id
127
+ },
128
+ onRestoreDone: (item) => {
129
+ finished.push(item)
130
+ },
131
+ })
132
+
133
+ if (failed > 0) process.exitCode = 1
134
+ return
135
+ }
136
+
137
+ case 'verify': {
138
+ if (!parsed.args[0]) {
139
+ throw new Error('Missing backup id. Example: npx telstore verify telstore-20260905-7f3a91')
140
+ }
141
+
142
+ const { runVerifies } = await import('../src/commands/verify.js')
143
+
144
+ const { failed } = await runVerifies(parsed.args, parsed.options)
125
145
 
146
+ // A backup that is damaged, and one telstore could not look up at all, both mean the
147
+ // run did not find what it was asked to check. Whatever runs telstore learns that here.
126
148
  if (failed > 0) process.exitCode = 1
127
149
  return
128
150
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "telstore",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Split large files into chunks and store them on Telegram",
5
5
  "keywords": [
6
6
  "telegram",
package/src/cli.js CHANGED
@@ -6,6 +6,7 @@ const SUBCOMMANDS = new Set([
6
6
  'logout',
7
7
  'list',
8
8
  'restore',
9
+ 'verify',
9
10
  'delete',
10
11
  'status',
11
12
  'config',
@@ -21,6 +22,7 @@ const OPTIONS = {
21
22
  out: { type: 'string' },
22
23
  note: { type: 'string' },
23
24
  limit: { type: 'string' },
25
+ search: { type: 'string' },
24
26
  verbose: { type: 'boolean' },
25
27
  unset: { type: 'boolean' },
26
28
  yes: { type: 'boolean' },
@@ -34,9 +36,11 @@ Usage:
34
36
  npx telstore login Log in to Telegram, only needed once
35
37
  npx telstore <file|folder|pattern>... Split files and upload them to Telegram
36
38
  npx telstore list List the backups stored in the destination
39
+ npx telstore list --search <text> List only the backups that text appears in
37
40
  npx telstore restore <backup-id>... Download the chunks and reassemble the files
41
+ npx telstore verify <backup-id>... Check that a backup's chunks are all still in the chat
38
42
  npx telstore delete <backup-id>... Remove backups' chunks and manifests from the chat
39
- npx telstore status Show the account, the destination and unfinished backups
43
+ npx telstore status Show the account, the destination and unfinished uploads and restores
40
44
  npx telstore config Show every setting and where its value comes from
41
45
  npx telstore logout Remove the saved session
42
46
 
@@ -50,8 +54,10 @@ backup. A folder means the files one level inside it, and a pattern means the na
50
54
  one file is listed and confirmed before the first byte goes out. Run telstore again with only
51
55
  the files that are left to carry on after an interruption.
52
56
 
53
- restore and delete take several ids the same way: one connection, one line each, and an exit
54
- code that reports any that failed. delete shows everything it is about to destroy and asks once.
57
+ restore, verify and delete take several ids the same way: one connection, one line each, and
58
+ an exit code that reports any that failed. delete shows everything it is about to destroy and
59
+ asks once. verify downloads nothing: it asks the chat whether every chunk message is still
60
+ there at the length the manifest records, which is what restore would need.
55
61
 
56
62
  Settings:
57
63
  npx telstore config <name> Print one setting's value
@@ -80,6 +86,12 @@ Options apply to one run and are never saved. Use config to change a setting for
80
86
  shell hands the words after the first to telstore as more files
81
87
  to upload.
82
88
  --limit <n> How many backups list shows this run.
89
+ --search <text> List only the backups whose file name, note, backup id or
90
+ creation day contains this text. Telegram's own index does
91
+ the looking, so the whole chat is reached without reading it
92
+ message by message โ€” but it matches whole words only:
93
+ "projex" finds projex.zip and "proj" finds nothing. A term
94
+ with spaces has to be quoted, as --note does.
83
95
  --token Log in by pasting a session token. It takes no value on
84
96
  purpose: a token written on the command line would sit in
85
97
  "ps" for the whole life of the command, and stay in that
@@ -120,8 +132,38 @@ export function interruptMessage(command, { backupId, done = [] } = {}) {
120
132
  )
121
133
  }
122
134
 
135
+ // A restore keeps its .partial now, and the next run proves each chunk in it against the
136
+ // manifest before trusting a byte โ€” so "running again starts over", which this said while
137
+ // there was nothing to resume from, would now be false.
123
138
  if (command === 'restore') {
124
- return '\nStopped. Download progress is not saved, running again starts over.\n'
139
+ // Finished ids have been renamed to their real names and their records removed, so
140
+ // repeating the whole command line would meet an overwrite prompt and then download
141
+ // them again from nothing. Name them and ask for the rest, exactly as a batch upload does.
142
+ if (done.length > 0) {
143
+ const width = Math.max(...done.map((item) => basename(item.path).length))
144
+ const finished = done
145
+ .map((item) => ` ${basename(item.path).padEnd(width)} ${item.id}`)
146
+ .join('\n')
147
+
148
+ return (
149
+ `\nStopped. These are finished and need no second run:\n${finished}\n` +
150
+ 'Run telstore again with only the ids that are left โ€” their .partial files are kept, ' +
151
+ 'so those carry on where they stopped. "npx telstore status" shows what is unfinished.\n'
152
+ )
153
+ }
154
+
155
+ // With onBackupId firing only once the .partial is open, an id here means there is a
156
+ // file to carry on from. Without one, this run stopped before it wrote anything, and
157
+ // saying a .partial was kept would be the same lie this message was rewritten to stop
158
+ // telling โ€” just from the other side.
159
+ if (!backupId) {
160
+ return '\nStopped before anything was written. Run the same command again to start.\n'
161
+ }
162
+
163
+ return (
164
+ `\nBackup ${backupId} kept its .partial file โ€” run the same command again from this ` +
165
+ 'directory to carry on, or "npx telstore status" to see what is left.\n'
166
+ )
125
167
  }
126
168
 
127
169
  // A delete has already destroyed messages for good by the time Ctrl-C lands, and the
package/src/client.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { Api, TelegramClient } from 'teleproto'
2
2
  import { Logger } from 'teleproto/extensions/index.js'
3
3
  import { LogLevel } from 'teleproto/extensions/Logger.js'
4
+ import { returnBigInt } from 'teleproto/Helpers.js'
4
5
  import { StringSession } from 'teleproto/sessions/index.js'
5
6
 
7
+ import { MANIFEST_TAG } from './caption.js'
6
8
  import { manifestFileName } from './manifest.js'
7
9
  import { withRetry } from './retry.js'
8
10
  import { assertLoggedIn, unlockConfig } from './session.js'
@@ -23,6 +25,26 @@ export function documentFileName(message) {
23
25
  return named?.fileName ?? null
24
26
  }
25
27
 
28
+ // Telegram records a document's length as a BigInteger, and comparing that to the plain
29
+ // number a manifest carries with === is false for every size there is.
30
+ export function documentSize(message) {
31
+ const size = message?.media?.document?.size
32
+
33
+ return size === undefined || size === null ? null : returnBigInt(size).toJSNumber()
34
+ }
35
+
36
+ // The flat shape both readers of a chat hand back. The raw message is kept alongside it
37
+ // because downloading needs it whole.
38
+ function toDocument(message) {
39
+ return {
40
+ id: message.id,
41
+ fileName: documentFileName(message),
42
+ caption: message.message ?? '',
43
+ date: message.date,
44
+ message,
45
+ }
46
+ }
47
+
26
48
  // The one place telstore searches a chat. Both callers want documents and nothing else,
27
49
  // and getMessages is preferred over a raw Api.messages.Search because it handles offsets,
28
50
  // hashes and pagination itself, so we don't hand-build easily mistyped fields. The raw
@@ -34,13 +56,93 @@ export async function searchDocuments(client, peer, { search, limit }) {
34
56
  limit,
35
57
  })
36
58
 
37
- return messages.map((message) => ({
38
- id: message.id,
39
- fileName: documentFileName(message),
40
- caption: message.message ?? '',
41
- date: message.date,
42
- message,
43
- }))
59
+ return messages.map(toDocument)
60
+ }
61
+
62
+ // The paging both readers share. It is ours rather than iterMessages', for the reasons
63
+ // deleteMessages does not use teleproto's: every page then carries the retry policy and the
64
+ // stall deadline, and a page that fails is retried by itself instead of restarting from the
65
+ // newest message. offsetId is the id of the last message of the page before, and Telegram
66
+ // answers with the messages older than it โ€” a reader that forgot to advance it would fetch
67
+ // the newest page over and over and never reach an older backup.
68
+ //
69
+ // A generator because the caller stops when it has what it wants: a chat of ten thousand
70
+ // chunks costs one request to list the backups at the top of it.
71
+ export const DOCUMENT_PAGE_SIZE = 100
72
+
73
+ async function* iterMessagePages(client, peer, { search, what, options }) {
74
+ const {
75
+ pageSize = DOCUMENT_PAGE_SIZE,
76
+ max = Infinity,
77
+ retryOptions = {},
78
+ stallMs = DEFAULT_STALL_MS,
79
+ } = options
80
+
81
+ let offsetId = 0
82
+ let read = 0
83
+
84
+ while (read < max) {
85
+ const limit = Math.min(pageSize, max - read)
86
+
87
+ const messages = await withRetry(
88
+ () =>
89
+ withStallTimeout(
90
+ client.getMessages(peer, {
91
+ ...(search === undefined ? {} : { search }),
92
+ filter: new Api.InputMessagesFilterDocument(),
93
+ limit,
94
+ offsetId,
95
+ }),
96
+ stallMs,
97
+ () =>
98
+ `Telegram stopped answering while reading the ${what} older than message ` +
99
+ `${offsetId}: nothing back for ${Math.round(stallMs / 1000)}s.`,
100
+ ),
101
+ retryOptions,
102
+ )
103
+
104
+ if (!messages || messages.length === 0) return
105
+
106
+ for (const message of messages) yield toDocument(message)
107
+
108
+ read += messages.length
109
+ offsetId = messages[messages.length - 1].id
110
+
111
+ // A short page is the end of the results. Asking again would cost a request to be told
112
+ // the same thing.
113
+ if (messages.length < limit) return
114
+ }
115
+ }
116
+
117
+ // What `list` reads the chat with. searchDocuments asks Telegram's text index a question;
118
+ // this asks for the documents themselves, newest first, which is the only answer that was
119
+ // right every time it was measured โ€” a chat's text index can come back empty while the chat
120
+ // is full of backups, and did for a whole day in a channel that had just been created
121
+ // (docs/design/captions.md carries the measurements).
122
+ export async function* iterDocuments(client, peer, options = {}) {
123
+ yield* iterMessagePages(client, peer, { what: 'documents', options })
124
+ }
125
+
126
+ // What `list --search` reads the chat with, and the one place that knows how to ask the index
127
+ // for backups. Two things about the query are load-bearing, both measured against a real chat
128
+ // on 2026-09-08 (docs/design/captions.md carries the numbers):
129
+ //
130
+ // The tag is ANDed in because a term alone brings the backup's chunk messages back too โ€” a
131
+ // chunk caption carries the id โ€” and on a backup of a few hundred chunks those would fill
132
+ // every page before a single manifest appeared. `#telstore` rides on the manifest alone, so
133
+ // the results come back manifests only: searching one backup id returned 1 manifest and 1
134
+ // chunk, and the same id with the tag returned the manifest by itself.
135
+ //
136
+ // The tag goes *after* the term, never before. A query that starts with the hash is read as
137
+ // a hashtag lookup and stops ANDing the rest: "#telstore projex" came back empty while
138
+ // "projex #telstore" returned the one manifest. Leading it would turn every search into
139
+ // "no backups found", which is the sentence this project must never say wrongly.
140
+ export async function* iterManifestSearch(client, peer, term, options = {}) {
141
+ yield* iterMessagePages(client, peer, {
142
+ search: `${term} ${MANIFEST_TAG}`,
143
+ what: 'search results',
144
+ options,
145
+ })
44
146
  }
45
147
 
46
148
  // How telstore finds a backup's manifest, in one place because restore and delete must not
@@ -70,11 +172,15 @@ export async function readMessageBytes(client, message) {
70
172
  //
71
173
  // Telegram does not complain about an id that is no longer there, so sending a batch twice
72
174
  // costs nothing: a delete interrupted halfway is finished by running it again.
73
- export const DELETE_BATCH_SIZE = 100
175
+ //
176
+ // The hundred is Telegram's own limit on how many message ids one request may name, and it
177
+ // is the same limit whether the request removes them or asks about them โ€” so getDocuments
178
+ // below counts in the same batches rather than keeping a second opinion about one number.
179
+ export const MESSAGE_BATCH_SIZE = 100
74
180
 
75
181
  export async function deleteMessages(client, peer, ids, options = {}) {
76
182
  const {
77
- batchSize = DELETE_BATCH_SIZE,
183
+ batchSize = MESSAGE_BATCH_SIZE,
78
184
  retryOptions = {},
79
185
  stallMs = DEFAULT_STALL_MS,
80
186
  onBatch,
@@ -110,6 +216,53 @@ export async function deleteMessages(client, peer, ids, options = {}) {
110
216
  return deleted
111
217
  }
112
218
 
219
+ // What verify asks the chat, and the read-only mirror of deleteMessages above: our own
220
+ // batching, one request in flight at a time, under the same retry policy and the same stall
221
+ // deadline as every other network wait in telstore.
222
+ //
223
+ // The answer is a Map rather than a list because the question is "which of these are still
224
+ // there". Telegram reports a message that is gone as MessageEmpty โ€” an object carrying the
225
+ // id it was asked about โ€” so anything that is not a real message is left out here, where the
226
+ // shape is understood, rather than passed on to a caller that would read an empty as a chunk
227
+ // still sitting in the chat.
228
+ export async function getDocuments(client, peer, ids, options = {}) {
229
+ const {
230
+ batchSize = MESSAGE_BATCH_SIZE,
231
+ retryOptions = {},
232
+ stallMs = DEFAULT_STALL_MS,
233
+ onBatch,
234
+ } = options
235
+
236
+ const found = new Map()
237
+
238
+ for (let start = 0; start < ids.length; start += batchSize) {
239
+ const batch = ids.slice(start, start + batchSize)
240
+
241
+ const messages = await withRetry(
242
+ () =>
243
+ withStallTimeout(
244
+ client.getMessages(peer, { ids: batch }),
245
+ stallMs,
246
+ () =>
247
+ `Telegram stopped answering while looking up messages ${start + 1}-` +
248
+ `${start + batch.length} of ${ids.length}: nothing back for ` +
249
+ `${Math.round(stallMs / 1000)}s.`,
250
+ ),
251
+ retryOptions,
252
+ )
253
+
254
+ for (const message of messages ?? []) {
255
+ if (!message || message instanceof Api.MessageEmpty) continue
256
+
257
+ found.set(message.id, message)
258
+ }
259
+
260
+ onBatch?.(Math.min(start + batch.length, ids.length), ids.length)
261
+ }
262
+
263
+ return found
264
+ }
265
+
113
266
  // Every command ends by putting the connection down, and a failure there must never
114
267
  // swallow the real error already on its way up. Commands that print progress hand in an
115
268
  // onWarn to say so; the quieter ones let it pass, because a connection that will not
@@ -1,6 +1,8 @@
1
+ import { promises as fs } from 'node:fs'
2
+
1
3
  import { chatName, describeChat } from '../chat.js'
2
4
  import {
3
- DELETE_BATCH_SIZE,
5
+ MESSAGE_BATCH_SIZE,
4
6
  closeQuietly,
5
7
  connect as realConnect,
6
8
  deleteMessages as realDeleteMessages,
@@ -13,7 +15,7 @@ import { manifestFileName, manifestMessageIds, parseManifestJson } from '../mani
13
15
  import { formatBytes, formatDuration } from '../progress.js'
14
16
  import { assertLoggedIn } from '../session.js'
15
17
  import { requireChat, resolveSettings } from '../settings.js'
16
- import { clearState, findStates } from '../state.js'
18
+ import { clearRestore, clearState, findRestores, findStates } from '../state.js'
17
19
 
18
20
  // What list prints when a card cannot be read back. A manifest is text off a chat, and a
19
21
  // summary is not worth inventing: the numbers below only decorate a decision the backup id
@@ -179,7 +181,7 @@ export async function runDelete(backupId, options = {}, deps = {}) {
179
181
  throw new Error('Cancelled on request.')
180
182
  }
181
183
 
182
- const loud = chunkIds.length > DELETE_BATCH_SIZE
184
+ const loud = chunkIds.length > MESSAGE_BATCH_SIZE
183
185
  let removed = 0
184
186
 
185
187
  try {
@@ -223,6 +225,30 @@ export async function runDelete(backupId, options = {}, deps = {}) {
223
225
 
224
226
  if (record) await clearState(record.key, configDir)
225
227
 
228
+ // The chunks are gone from the chat, so a restore record pointing at this backup now
229
+ // names messages nobody can fetch: `status` would keep offering a resume command that
230
+ // can only fail. Dropped here rather than earlier for the same reason the upload record
231
+ // is โ€” anything that throws above leaves the way back intact.
232
+ //
233
+ // The .partial itself stays. It is the user's data, sometimes gigabytes of it, and this
234
+ // command removes what was asked for and nothing else. But it can never be completed
235
+ // now, so it is named on the way out: that is the difference between a file they can
236
+ // reclaim and one they will never think to look for.
237
+ const stranded = []
238
+
239
+ for (const found of await findRestores(backupId, configDir)) {
240
+ await clearRestore(found.key, configDir)
241
+
242
+ const partial = `${found.record.target}.partial`
243
+
244
+ try {
245
+ await fs.stat(partial)
246
+ stranded.push(partial)
247
+ } catch {
248
+ // Nothing there to tell them about.
249
+ }
250
+ }
251
+
226
252
  if (manifestMessage) {
227
253
  log(
228
254
  `\nDone. Removed ${backupId} from ${chatName(chat)}: ` +
@@ -236,6 +262,13 @@ export async function runDelete(backupId, options = {}, deps = {}) {
236
262
  )
237
263
  }
238
264
 
265
+ for (const partial of stranded) {
266
+ log(
267
+ `${partial} is a half-finished restore of this backup. Nothing can finish it now โ€” ` +
268
+ 'delete it when you want the space back.',
269
+ )
270
+ }
271
+
239
272
  return {
240
273
  id: backupId,
241
274
  chunks: chunkIds.length,