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.
@@ -4,10 +4,11 @@ import { countChunks } from '../chunking.js'
4
4
  import { describeChat } from '../chat.js'
5
5
  import { closeQuietly, connect as realConnect } from '../client.js'
6
6
  import { configFile, defaultConfigDir, loadConfig } from '../config.js'
7
- import { formatBytes } from '../progress.js'
7
+ import { formatBytes, plural } from '../progress.js'
8
8
  import { assertLoggedIn } from '../session.js'
9
9
  import { resolveSettings } from '../settings.js'
10
- import { canResume, listRestores, listStates } from '../state.js'
10
+ import { deleteCommand, shellArg } from '../shell.js'
11
+ import { canResume, listRestores, listStates, listTempChunks, tempDirFor } from '../state.js'
11
12
 
12
13
  const LABEL_WIDTH = 'Destination'.length + 2
13
14
 
@@ -24,29 +25,59 @@ function field(label, value) {
24
25
  return ` ${label.padEnd(FIELD_WIDTH)}${value}`
25
26
  }
26
27
 
27
- // The Resume line is a command meant to be pasted, so anything a shell would take apart has
28
- // to come back quoted — a path with a space in it is the ordinary case, not an exotic one.
29
- const BARE_ARG = /^[A-Za-z0-9_@%+:,./-]+$/
30
-
31
- function shellArg(text) {
32
- const value = String(text)
33
-
34
- return BARE_ARG.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`
35
- }
36
-
37
28
  // runUpload refuses to send the rest of a backup to a different chat, so the command has to
38
29
  // name the one the chunks are already in — unless the destination in force is that chat
39
30
  // anyway, where --chat would just be noise. Not knowing the destination counts as not matching:
40
31
  // leaving --chat out would be a guess about where a backup already in progress went.
32
+ //
33
+ // The delete command this file also prints does the opposite and always names the chat, and
34
+ // the two are not the same risk: a resume is the same upload again, and runUpload refuses
35
+ // outright to send the rest of a backup somewhere else, while a delete pasted a week later
36
+ // would destroy whatever happens to carry those ids in the chat it resolves. That rule lives
37
+ // in `deleteCommand` in shell.js, for all four places that print the line; `recordChat` below
38
+ // is what keeps this caller away from its chatless branch.
41
39
  function resumeCommand(state, destination) {
42
40
  const matches = destination !== null && state.chat === String(destination)
43
41
 
44
42
  return `npx telstore ${shellArg(state.path)}${matches ? '' : ` --chat ${shellArg(state.chat)}`}`
45
43
  }
46
44
 
45
+ // status is the command someone runs *because* something is wrong, so a record a truncated
46
+ // write or a hand edit mangled is nearer its normal case than its edge case. These two read
47
+ // what a stream record claims and say when it claims nothing, because the alternative is the
48
+ // report this block was written to end: `From undefined`, `--chat undefined`.
49
+ //
50
+ // Named for the record it reads, not for the job: `down` describes both kinds of record and
51
+ // answers differently on purpose, and one name over two answers is how the two drifted apart
52
+ // far enough that a whitespace-only name printed as blank in one of them.
53
+ function describeStreamSource(state) {
54
+ return typeof state.name === 'string' && state.name.trim() !== ''
55
+ ? `${state.name} (a command's output)`
56
+ : "a command's output this record does not name"
57
+ }
58
+
59
+ // Null is what stops a delete command being built at all. A record that cannot say where its
60
+ // chunks went would produce `--chat undefined`, and a command carrying that is worse than no
61
+ // command: runDelete would take it as no destination at all, resolve one from config, and
62
+ // fire these message ids at whatever peer that turns out to be. Written as String(chat), so
63
+ // anything else here is damage; a number is still taken, because a channel id is one.
64
+ function recordChat(state) {
65
+ const { chat } = state
66
+
67
+ if (typeof chat === 'number' && Number.isFinite(chat)) return String(chat)
68
+ if (typeof chat === 'string' && chat.trim() !== '') return chat
69
+
70
+ return null
71
+ }
72
+
47
73
  // Why a resume is off the table, in the words of the thing the user would have to fix. The
48
74
  // record is keyed on the file's path, size and mtime, so any of these means runUpload would
49
75
  // hash the file to a different key, find nothing, and start a second backup instead.
76
+ //
77
+ // 'stream' is deliberately absent: a stream record never reaches here, because it is not an
78
+ // unfinished transfer at all and gets its own block below. The fallback beside the lookup is
79
+ // for the reason canResume grows next — an unnamed reason is a report that reads "not
80
+ // possible: undefined", which is the one thing this file must never print.
50
81
  const NO_RESUME = {
51
82
  missing: 'the file is no longer there',
52
83
  changed: 'the file has changed since the backup started',
@@ -67,12 +98,14 @@ const NO_PARTIAL_RESUME = {
67
98
  // telling the user to run something that quietly starts a second backup and abandons every
68
99
  // chunk this one already sent — and those chunks are then findable only by this id, which
69
100
  // is worth saying while there are any.
70
- async function resumeLines(key, state, destination, done) {
71
- const check = await canResume(key, state)
72
-
101
+ //
102
+ // The check is passed in rather than made here: the caller has to know a stream record before
103
+ // it prints a File row, and asking canResume twice is how the two answers start to drift.
104
+ function resumeLines(check, state, destination, done) {
73
105
  if (check.ok) return [field('Resume', resumeCommand(state, destination))]
74
106
 
75
- const lines = [field('Resume', `not possible: ${NO_RESUME[check.reason]}.`)]
107
+ const reason = NO_RESUME[check.reason] ?? `the record cannot be resumed (${check.reason})`
108
+ const lines = [field('Resume', `not possible: ${reason}.`)]
76
109
 
77
110
  if (done > 0) {
78
111
  lines.push(
@@ -111,6 +144,66 @@ async function restoreResumeLine(record, destination) {
111
144
  return field('Resume', restoreCommand(record, destination))
112
145
  }
113
146
 
147
+ // The one thing under ~/.telstore that is not a record. A stream upload buffers a chunk into
148
+ // ~/.telstore/tmp while it sends it and removes it as it goes, so a file there is either a run
149
+ // happening at this moment or a run that was stopped where it stood — a SIGKILL, a crash, a
150
+ // machine losing power, and until this branch a second Ctrl-C. What it costs is a whole chunk
151
+ // of disk, 1800MB by default, that nothing on this machine mentions: the backup's record can be
152
+ // cleared by a `delete` the run itself printed, after which `status` says "Unfinished none"
153
+ // over 37MB of leftovers — measured in the e2e channel, three runs, 2026-09-09. `down` removes
154
+ // them, and `down` is the command that removes everything, so somebody who only wants their
155
+ // disk back has to be told here instead.
156
+ //
157
+ // Named, never removed — not by status and not by any other run. From outside the run that owns
158
+ // one, a file being filled right now and a file left by a run that died are the same file, and
159
+ // removing the first is the confident wrong answer this project refuses everywhere else. So the
160
+ // listing says what is there and what the two possibilities are, and the person decides. That is
161
+ // the same choice `down` makes about entries it did not put in the directory.
162
+ function tempChunkLines(temp, configDir, error) {
163
+ // Not silence, because "nothing is there" is exactly what this cannot know: a directory
164
+ // that will not open may be holding a whole chunk. Said as the one line it is, with the
165
+ // rest of the report still around it, the same way a settings row that will not parse is.
166
+ if (error !== null) {
167
+ return [
168
+ '',
169
+ `${tempDirFor(configDir)} could not be read: ${error}. A backup made from a command,`,
170
+ 'or a restore into one, buffers a chunk there, so it may be holding up to a whole chunk',
171
+ 'of disk.',
172
+ ]
173
+ }
174
+
175
+ if (temp.length === 0) return []
176
+
177
+ const known = temp.filter((entry) => entry.size !== null)
178
+ const total = known.reduce((sum, entry) => sum + entry.size, 0)
179
+ // "at least" rather than a number that quietly leaves one out: a file whose size could not
180
+ // be read is still holding whatever it is holding.
181
+ const size = known.length === temp.length ? formatBytes(total) : `at least ${formatBytes(total)}`
182
+ const width = Math.max(...temp.map((entry) => entry.name.length))
183
+
184
+ const lines = [
185
+ '',
186
+ `${plural(temp.length, 'chunk file')}, ${size} in all, in ${tempDirFor(configDir)}:`,
187
+ '',
188
+ ]
189
+
190
+ for (const entry of temp) {
191
+ const held = entry.size === null ? 'size unknown' : formatBytes(entry.size)
192
+
193
+ lines.push(` ${entry.name.padEnd(width)} ${held}`)
194
+ }
195
+
196
+ lines.push('')
197
+ lines.push('A backup made from a command, or a restore into one, buffers one chunk here while')
198
+ lines.push('it sends or receives it and removes it afterwards, so these are either runs')
199
+ lines.push('happening right now or runs that were killed before they could clean up. telstore')
200
+ lines.push('does not remove them on its own — from outside the run that owns one it cannot')
201
+ lines.push('tell those two apart. Remove one with:')
202
+ lines.push(` rm ${shellArg(temp[0].file)}`)
203
+
204
+ return lines
205
+ }
206
+
114
207
  // Only the kinds actually present are named. "N backups" fits an upload and not a restore:
115
208
  // there the backup is finished and sitting in the chat, and it is the restore that stopped.
116
209
  function unfinishedCount(uploads, restores) {
@@ -207,7 +300,42 @@ export async function runStatus(options = {}, deps = {}) {
207
300
 
208
301
  log(row('Unfinished', unfinishedCount(uploads.length, restores.length)))
209
302
 
210
- if (uploads.length === 0 && restores.length === 0) return
303
+ // Read before the records are printed and reported after them, and it has to be both: a
304
+ // machine with nothing unfinished on it is exactly where a stranded chunk hides, because
305
+ // that used to be where this report ended.
306
+ //
307
+ // Caught here for the reason every other failure in this command is: status is what someone
308
+ // runs *because* something is wrong, and an unreadable tmp directory must not take the
309
+ // account, the destination and the unfinished backups down with it.
310
+ let temp = []
311
+ let tempError = null
312
+
313
+ try {
314
+ temp = await listTempChunks(configDir)
315
+ } catch (err) {
316
+ tempError = err.message
317
+ }
318
+
319
+ // In a `finally`, so one damaged record — or a `telstore status | head` that closes the pipe
320
+ // under the write — cannot hide up to 1.8GB of borrowed disk. That is the early `return` this
321
+ // task removed, one layer up: the listing came last, so anything that stopped short of it took
322
+ // it with it, and status is the command someone runs *because* something is already wrong. The
323
+ // error still leaves by its own route; it just does not leave alone.
324
+ try {
325
+ if (uploads.length > 0 || restores.length > 0) {
326
+ for (const line of await unfinishedLines(uploads, restores, settings)) log(line)
327
+ }
328
+ } finally {
329
+ for (const line of tempChunkLines(temp, configDir, tempError)) log(line)
330
+ }
331
+ }
332
+
333
+ // Everything below the rows: one indented block per unfinished transfer, newest first.
334
+ // Split out of runStatus only so the temp-chunk listing after it cannot be skipped by an
335
+ // early return — which is how it came to be missing in the first place.
336
+ async function unfinishedLines(uploads, restores, settings) {
337
+ const lines = []
338
+ const log = (line) => lines.push(line)
211
339
 
212
340
  // The destination is what decides whether a resume command needs a --chat. A row that
213
341
  // failed to parse leaves nothing to compare against, which is not the same as a match.
@@ -235,14 +363,57 @@ export async function runStatus(options = {}, deps = {}) {
235
363
  }
236
364
 
237
365
  const { key, state } = entry
238
- const total = countChunks(state.size, state.chunkSize)
366
+ const resume = await canResume(key, state)
239
367
  const done = Object.keys(state.done ?? {}).length
240
368
 
241
369
  log(` ${state.id}`)
370
+
371
+ // A stream record is not an unfinished transfer waiting to be picked up. It has no path,
372
+ // no length and no chunk count to be so far through — the bytes came from a command's
373
+ // stdout, they have gone past, and the next run cuts them differently. What it names is
374
+ // chunks sitting in a chat with nothing pointing at them, which is a different sentence
375
+ // and a different command: the only thing anyone can do with them is remove them.
376
+ if (resume.reason === 'stream') {
377
+ const chat = recordChat(state)
378
+
379
+ // Whether a manifest went out is the difference between chunks nothing can name and a
380
+ // backup that is whole but unrecorded, and the record is what says which — a rollback
381
+ // that could not finish writes the card's id here. status asks Telegram nothing in this
382
+ // block, so it reports the record's claim as the record's claim rather than as a fact
383
+ // about the chat, which is the same care delete takes when its search comes up empty.
384
+ //
385
+ // `== null` rather than `=== undefined`, because delete's stateManifestId reads this
386
+ // same field and treats null and absent alike. A record carrying an explicit null — a
387
+ // hand edit, or a writer that spelled "nothing here" out — would otherwise have status
388
+ // promise a manifest while delete reports none: two commands contradicting each other
389
+ // about one record, which is the failure this whole block exists to end.
390
+ const manifest =
391
+ state.manifestMsgId == null
392
+ ? 'with no manifest naming them'
393
+ : 'and the manifest its record names'
394
+
395
+ log(field('From', describeStreamSource(state)))
396
+ log(field('Chunks', `${plural(done, 'chunk')} in the chat, ${manifest}`))
397
+ log(field('Chat', chat === null ? 'the record does not say' : describeChat(chat)))
398
+ log(
399
+ field(
400
+ 'Remove',
401
+ chat === null
402
+ ? 'not possible: the record does not say which chat the chunks are in.'
403
+ : deleteCommand(state.id, chat),
404
+ ),
405
+ )
406
+ continue
407
+ }
408
+
409
+ const total = countChunks(state.size, state.chunkSize)
410
+
242
411
  log(field('File', `${state.path} (${formatBytes(state.size)})`))
243
412
  log(field('Chunks', `${done} of ${total} uploaded`))
244
413
  log(field('Chat', describeChat(state.chat)))
245
414
 
246
- for (const line of await resumeLines(key, state, destination, done)) log(line)
415
+ for (const line of resumeLines(resume, state, destination, done)) log(line)
247
416
  }
417
+
418
+ return lines
248
419
  }