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.
@@ -7,12 +7,19 @@ import {
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 { manifestFileName, manifestMessageIds, parseManifestJson } from '../manifest.js'
15
- import { formatBytes, formatDuration } from '../progress.js'
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 manifestMessage = await searchManifest(client, chat, backupId)
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
- if (manifestMessage) {
134
- manifest = parseManifestJson(await readMessageBytes(client, manifestMessage))
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 (manifest?.id !== undefined && manifest.id !== backupId) {
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(manifest.id)}, not ${backupId}. Its message ids point at ` +
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(manifest)) ids.add(id)
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
- const chunkIds = [...ids]
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,17 +390,36 @@ export async function runDelete(backupId, options = {}, deps = {}) {
165
390
  `(${describeSize(manifest.size)}, ${plural(manifest.chunks.length, 'chunk')})`,
166
391
  )
167
392
  } else {
168
- log(`Backup ${backupId} (unfinished — no manifest in the chat)`)
169
- log(`File ${describeName(record.state.path)}`)
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, and its local ` +
178
- `record? The chunks cannot be recovered. [y/N] `
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.')
@@ -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
- : 'The local record was left in place on purpose — it is the only list of the ' +
203
- 'messages that are still there. ') +
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 (manifestMessage) {
464
+ if (manifestId !== null) {
214
465
  try {
215
- await deleteMessages(client, chat, [manifestMessage.id], {
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
- if (manifestMessage) {
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
- `\nDone. Removed ${backupId} from ${chatName(chat)}: ` +
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
- `\nDone. Removed ${plural(chunkIds.length, 'chunk message')} from ${chatName(chat)} ` +
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: Boolean(manifestMessage),
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(', ')} — not found in ${chatName(chat)}, and no ` +
373
- 'local record of it on this machine either. Check the ids with "npx telstore list".',
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
- : `${describeName(record?.state?.path)} (unfinished)`,
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
- ? ` ${id} failed: ${result.error}`
474
- : ` ${id} ${plural(result.chunks, 'chunk message')} removed` +
475
- (result.manifestDeleted ? ' with its manifest' : '')
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
  }