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/status.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
71
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
415
|
+
for (const line of resumeLines(resume, state, destination, done)) log(line)
|
|
247
416
|
}
|
|
417
|
+
|
|
418
|
+
return lines
|
|
248
419
|
}
|