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.
package/src/progress.js CHANGED
@@ -47,6 +47,57 @@ export function renderProgress({ done, total, elapsedMs, label, width = 24, tran
47
47
  return `${label} ${bar} ${percent}% ${formatBytes(done)}/${formatBytes(total)} ${speed} ETA ${formatDuration(remaining)}`
48
48
  }
49
49
 
50
+ // A percentage of an unknown total is an invented number, and an ETA from one is worse: it
51
+ // would count down to a finish nobody can predict.
52
+ export function renderStreamProgress({ done, elapsedMs, label }) {
53
+ const bytesPerSecond = elapsedMs > 0 ? done / (elapsedMs / 1000) : 0
54
+
55
+ return `${label} ${formatBytes(done)} sent ${formatBytes(Math.round(bytesPerSecond))}/s`
56
+ }
57
+
58
+ export function createStreamProgress({
59
+ label,
60
+ write = (line) => process.stderr.write(line),
61
+ now = () => Date.now(),
62
+ minIntervalMs = 200,
63
+ }) {
64
+ const startedAt = now()
65
+ let done = 0
66
+ let currentLabel = label
67
+ let lastDrawnAt = startedAt
68
+ let widestLine = 0
69
+
70
+ // Same \r discipline as createProgress: a redraw shorter than the one before it would
71
+ // leave the previous line's tail on screen, so pad every line out to the widest drawn so far.
72
+ function draw(suffix) {
73
+ const line = renderStreamProgress({ done, elapsedMs: now() - startedAt, label: currentLabel })
74
+ widestLine = Math.max(widestLine, line.length)
75
+ write(`\r${line.padEnd(widestLine)}${suffix}`)
76
+ }
77
+
78
+ return {
79
+ advance(bytes) {
80
+ done += bytes
81
+ if (now() - lastDrawnAt < minIntervalMs) return
82
+ lastDrawnAt = now()
83
+ draw('')
84
+ },
85
+ setLabel(next) {
86
+ currentLabel = next
87
+ lastDrawnAt = now()
88
+ draw('')
89
+ },
90
+ finish() {
91
+ draw('\n')
92
+ },
93
+ }
94
+ }
95
+
96
+ // A number turned into words a person reads: "1 chunk" for one, "3 chunks" for the rest.
97
+ export function plural(n, word) {
98
+ return `${n} ${word}${n === 1 ? '' : 's'}`
99
+ }
100
+
50
101
  export function createProgress({
51
102
  total,
52
103
  label,
@@ -99,3 +150,38 @@ export function createProgress({
99
150
  },
100
151
  }
101
152
  }
153
+
154
+ // A walk of one page is over in about the time it takes to notice — 165ms against a real
155
+ // chat — and that is the usual case, so nothing is drawn for the first stretch: a line that
156
+ // appears and is wiped in the same breath is a flicker, not information. Past that the read
157
+ // is long enough that silence reads as the hang this project refuses everywhere else.
158
+ //
159
+ // \r only moves the cursor home, so every line is padded to the widest one drawn and the last
160
+ // write wipes the row: whatever the command prints next must never land on half a notice.
161
+ //
162
+ // Here rather than beside either caller: `list` walks a chat to find backups and `delete`
163
+ // walks it to find chunks nothing on this machine names, and two copies of "when is a read
164
+ // long enough to say something about" is how they start disagreeing about it.
165
+ const NOTICE_QUIET_MS = 400
166
+ const NOTICE_INTERVAL_MS = 200
167
+
168
+ export function createWalkNotice({ write, now, quietMs = NOTICE_QUIET_MS, intervalMs = NOTICE_INTERVAL_MS }) {
169
+ const startedAt = now()
170
+ let lastDrawnAt = 0
171
+ let widest = 0
172
+
173
+ return {
174
+ tick(text) {
175
+ if (now() - startedAt < quietMs) return
176
+ if (lastDrawnAt !== 0 && now() - lastDrawnAt < intervalMs) return
177
+
178
+ lastDrawnAt = now()
179
+ widest = Math.max(widest, text.length)
180
+ write(`\r${text.padEnd(widest)}`)
181
+ },
182
+ clear() {
183
+ if (widest === 0) return
184
+ write(`\r${' '.repeat(widest)}\r`)
185
+ },
186
+ }
187
+ }
package/src/shell.js ADDED
@@ -0,0 +1,33 @@
1
+ // A command meant to be pasted has to survive the shell that receives it: anything a shell
2
+ // would take apart comes back quoted, and a path with a space in it is the ordinary case,
3
+ // not an exotic one. `status` and `down` both print resume commands, and two copies of this
4
+ // rule is how they start disagreeing about which paths are safe to print bare.
5
+ const BARE_ARG = /^[A-Za-z0-9_@%+:,./-]+$/
6
+
7
+ export function shellArg(text) {
8
+ const value = String(text)
9
+
10
+ return BARE_ARG.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`
11
+ }
12
+
13
+ // The one line in telstore that destroys data when it is wrong. `delete` resolves its own
14
+ // destination from config and then fires the recorded message ids at whatever peer that turns
15
+ // out to be, so a command pasted next week — or one built here under a `--chat` this run was
16
+ // given — would remove whatever happens to carry those ids in the chat it resolves. Naming a
17
+ // chat that turns out to be the default costs a few characters; leaving it out when it is not
18
+ // costs somebody else's messages, and nothing undoes that. Four commands print this string
19
+ // (`upload-stream`'s rollback, `cli`'s second Ctrl-C, `status`, `down`) and they had four
20
+ // copies of the rule, which is how three of them stayed right and one drifted.
21
+ //
22
+ // The chatless branch is not a convenience: it is for the single caller that genuinely does
23
+ // not know where the chunks went. `bin/telstore.js` holds the chat the run handed it, and a
24
+ // Ctrl-C arriving before the run ever reported one leaves the id — the only part of the
25
+ // message with any value — rather than printing `--chat undefined`, which `delete` would read
26
+ // as no destination at all while looking like one. A caller that would rather print nothing
27
+ // than a command missing its chat decides that for itself before calling: `down` does, and
28
+ // says why beside its own check.
29
+ export function deleteCommand(id, chat) {
30
+ const where = chat === null || chat === undefined ? '' : String(chat).trim()
31
+
32
+ return `npx telstore delete ${shellArg(id)}${where === '' ? '' : ` --chat ${shellArg(where)}`}`
33
+ }
package/src/spawn.js ADDED
@@ -0,0 +1,38 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ // stderr is inherited, never captured: when a producer command fails it explains itself in
4
+ // its own words, on the stream the user is already watching. telstore adds the exit code
5
+ // and what it did about it, and does not paraphrase.
6
+ //
7
+ // `exited` can be built before the caller has any chance to await it — the caller reads
8
+ // `stdout` first, and only awaits `exited` once the stream ends. A rejected promise with no
9
+ // handler attached yet makes Node report an unhandledRejection, so a no-op `.catch` is
10
+ // attached here immediately. That does not consume the rejection: the `exited` this function
11
+ // returns is the same promise, and `await`ing it later still resolves or rejects exactly as
12
+ // it would have.
13
+ export function spawnProducer(argv, { stdio = ['ignore', 'pipe', 'inherit'] } = {}) {
14
+ const [command, ...args] = argv
15
+ const child = spawn(command, args, { stdio })
16
+
17
+ const exited = new Promise((resolve, reject) => {
18
+ child.on('error', (err) => {
19
+ reject(
20
+ new Error(
21
+ err.code === 'ENOENT'
22
+ ? `Cannot run ${command}: no such command on this machine.`
23
+ : `Cannot run ${command}: ${err.message}`,
24
+ ),
25
+ )
26
+ })
27
+
28
+ child.on('close', (code, signal) => resolve({ code, signal }))
29
+ })
30
+ exited.catch(() => {})
31
+
32
+ return {
33
+ stdout: child.stdout,
34
+ stdin: child.stdin,
35
+ exited,
36
+ kill: (signal = 'SIGTERM') => child.kill(signal),
37
+ }
38
+ }
package/src/state.js CHANGED
@@ -8,6 +8,69 @@ export function stateDir(configDir = defaultConfigDir()) {
8
8
  return path.join(configDir, 'state')
9
9
  }
10
10
 
11
+ // The other thing telstore keeps on this machine, and the only one that is not a record: a
12
+ // stream upload borrows one chunk of disk at a time here while it sends it. Under
13
+ // ~/.telstore rather than os.tmpdir() because /tmp is tmpfs on many Linux distributions, and
14
+ // "borrow one chunk of disk" would silently mean "borrow 1800MB of RAM" — a memory limit
15
+ // dressed up as a chunk size.
16
+ //
17
+ // It lives beside stateDir because it answers the same question — what has this machine got
18
+ // of telstore's on it — and because `status` has to be able to ask without importing the
19
+ // upload command, which would drag a second upload loop and teleproto in with it.
20
+ export function tempDirFor(configDir = defaultConfigDir()) {
21
+ return path.join(configDir, 'tmp')
22
+ }
23
+
24
+ // What is in there now. A run removes its own chunk file on every ending it gets to run code
25
+ // for, so a file here is either a run happening at this moment or a run that was stopped
26
+ // where it stood — a SIGKILL, a crash, a machine losing power. Nothing here removes them:
27
+ // from outside the run that owns one, those two cases look exactly the same, and deleting
28
+ // the chunk a live upload is filling is the confident wrong thing this project refuses
29
+ // everywhere else. Naming them is the whole job.
30
+ //
31
+ // A stat that fails yields an unknown size rather than a dropped row, the same care
32
+ // listStates takes with mtimes: the file really can vanish between the readdir and the stat —
33
+ // that is what a run finishing normally does — and status is the command someone runs
34
+ // *because* something is wrong.
35
+ export async function listTempChunks(configDir = defaultConfigDir()) {
36
+ const dir = tempDirFor(configDir)
37
+ let names
38
+
39
+ try {
40
+ names = await fs.readdir(dir)
41
+ } catch (err) {
42
+ // A machine that has never made a backup from a command has no such directory, and that
43
+ // is not a fault to report. Anything else is: a directory telstore cannot read may be
44
+ // holding a whole chunk, and answering "nothing there" would be the silent wrong answer
45
+ // this listing exists to prevent. The caller decides what to do with it.
46
+ if (err.code === 'ENOENT') return []
47
+
48
+ throw err
49
+ }
50
+
51
+ const found = []
52
+
53
+ for (const name of names.sort()) {
54
+ const file = path.join(dir, name)
55
+ let size = null
56
+
57
+ try {
58
+ const stat = await fs.stat(file)
59
+
60
+ if (!stat.isFile()) continue
61
+
62
+ size = stat.size
63
+ } catch {
64
+ // Gone or unreadable between the readdir and here. Still a name worth printing: the
65
+ // point of the listing is that nothing telstore left behind goes unmentioned.
66
+ }
67
+
68
+ found.push({ name, file, size })
69
+ }
70
+
71
+ return found
72
+ }
73
+
11
74
  export function stateKey(absPath, size, mtimeMs) {
12
75
  return createHash('sha1').update(`${absPath}:${size}:${mtimeMs}`).digest('hex')
13
76
  }
@@ -16,6 +79,14 @@ export function stateFile(key, configDir = defaultConfigDir()) {
16
79
  return path.join(stateDir(configDir), `${key}.json`)
17
80
  }
18
81
 
82
+ // stateKey hashes path:size:mtime, and a stream has none of the three. What holds still is
83
+ // the backup id, and hashing it keeps the file name in the same 40-hex shape the directory
84
+ // already sorts, prunes and filters on — a stream record is an upload record, not a third
85
+ // kind, so it shares that namespace rather than getting a prefix of its own.
86
+ export function streamKey(backupId) {
87
+ return createHash('sha1').update(`stream:${backupId}`).digest('hex')
88
+ }
89
+
19
90
  // A restore's record is filed beside the uploads and must never compete with them for a
20
91
  // prune slot. Losing an upload record strands chunks in a chat where only the id can still
21
92
  // find them, which is why pruneStates reads each file back to name what it drops; losing a
@@ -218,6 +289,13 @@ export async function findStates(backupId, configDir = defaultConfigDir()) {
218
289
  // Never throws. status calls this for every record it prints, and one damaged path must not
219
290
  // take the rest of the report down with it.
220
291
  export async function canResume(key, state) {
292
+ // A stream cannot be resumed by anyone, so this is not a question about a file. Answering
293
+ // it by stat-ing state.path would report "missing" for a record that never had a path,
294
+ // and status would then offer a resume command that starts a brand new backup. This has
295
+ // to run before the stat below, not after it fails: a stream record's key could still
296
+ // happen to match a real file on disk, and that file is not what makes it unresumable.
297
+ if (state.kind === 'stream') return { ok: false, reason: 'stream' }
298
+
221
299
  let stat
222
300
 
223
301
  try {
package/src/stream.js ADDED
@@ -0,0 +1,295 @@
1
+ import { promises as fs } from 'node:fs'
2
+
3
+ import { formatBytes } from './progress.js'
4
+
5
+ // `handle.write(buffer)` is not guaranteed to write the whole buffer in one call. `bytes`
6
+ // becomes the chunk's recorded length, so trusting an unchecked write would let telstore
7
+ // claim more reached disk than actually did — silently wrong data, the one thing this
8
+ // project refuses to produce. Loops until every byte of `buffer` has landed, or throws if
9
+ // a write reports zero bytes written (which would otherwise spin forever rather than fail).
10
+ async function writeFully(handle, buffer) {
11
+ let written = 0
12
+
13
+ while (written < buffer.length) {
14
+ const { bytesWritten } = await handle.write(buffer.subarray(written))
15
+
16
+ if (bytesWritten === 0) {
17
+ throw new Error('write wrote 0 bytes; refusing to spin retrying it')
18
+ }
19
+
20
+ written += bytesWritten
21
+ }
22
+ }
23
+
24
+ // One reader over the life of an upload: it holds the iterator, so the bytes a chunk did
25
+ // not want are the first bytes of the next chunk rather than something dropped between
26
+ // two reads. Pulling through an async iterator is also what gives backpressure for free —
27
+ // while a chunk uploads, nothing calls next(), so the child blocks on its own write and
28
+ // the backlog stays in the pipe instead of in this process.
29
+ export class ChunkReader {
30
+ constructor(readable) {
31
+ this.readable = readable
32
+ this.iterator = readable[Symbol.asyncIterator]()
33
+ this.pending = null
34
+ this.ended = false
35
+ this.error = null
36
+ }
37
+
38
+ // Writes at most `limit` bytes into `handle`. `eof` means the stream is finished and
39
+ // there will never be more — reported on the fill that meets the end, and on every one
40
+ // after it. An error from the source propagates out of the awaited `next()` call as a
41
+ // rejection of this method; it is never mistaken for `done`.
42
+ //
43
+ // Once the source has thrown, every later call rejects with that same error rather than
44
+ // asking the iterator again. An async generator that has already thrown reports `done:
45
+ // true` on the next `next()` call rather than throwing again, which — left unguarded —
46
+ // would turn one real failure into a clean end of stream on the very next fill.
47
+ async fill(handle, limit) {
48
+ if (this.error !== null) throw this.error
49
+
50
+ let bytes = 0
51
+
52
+ while (bytes < limit) {
53
+ if (this.pending === null) {
54
+ if (this.ended) break
55
+
56
+ let next
57
+ try {
58
+ next = await this.iterator.next()
59
+ } catch (err) {
60
+ this.error = err
61
+ throw err
62
+ }
63
+
64
+ const { value, done } = next
65
+
66
+ if (done) {
67
+ this.ended = true
68
+ break
69
+ }
70
+
71
+ this.pending = value
72
+ }
73
+
74
+ const room = limit - bytes
75
+ const take = this.pending.length <= room ? this.pending : this.pending.subarray(0, room)
76
+
77
+ await writeFully(handle, take)
78
+ bytes += take.length
79
+
80
+ this.pending =
81
+ take.length === this.pending.length ? null : this.pending.subarray(take.length)
82
+ }
83
+
84
+ return { bytes, eof: this.ended && this.pending === null }
85
+ }
86
+
87
+ // What a failed upload calls on the way out. The iterator is abandoned mid-stream then, and
88
+ // it is the iterator — not the caller — that holds the source open: returning it is what
89
+ // releases the stream, so a producer blocked writing into a pipe nobody is reading stops
90
+ // being blocked. Nothing it has to say can matter by then, because an error is already on
91
+ // its way out of the caller, so a refusal to close is swallowed rather than thrown over it.
92
+ //
93
+ // The source is destroyed as well, and not for symmetry. `return()` on an async generator
94
+ // that has never been started does not run the body, so it never reaches the `finally` a
95
+ // Node stream's iterator destroys the stream in — measured on node 22, not assumed. A
96
+ // reader closed before its first fill() would otherwise leave the child blocked on a pipe
97
+ // with nothing to unblock it.
98
+ async close() {
99
+ // An abort is not an end. Arming the same sticky error a producer's own failure sets is
100
+ // what stops a later fill() answering `eof: true` for a read that stopped with bytes
101
+ // still unread — mistaking one for the other is the thing this class exists to refuse.
102
+ // `??=` because the reason the source stopped is worth more than the fact that telstore
103
+ // then closed it, and close() runs on the path where that reason is already on its way
104
+ // out to the caller.
105
+ this.error ??= new Error(
106
+ 'The stream was closed before it ended, so nothing more can be read from it.',
107
+ )
108
+ this.pending = null
109
+
110
+ try {
111
+ await this.iterator.return?.()
112
+ } catch {
113
+ // Nothing here can change what already went wrong.
114
+ }
115
+
116
+ try {
117
+ this.readable.destroy?.()
118
+ } catch {
119
+ // As above.
120
+ }
121
+ }
122
+ }
123
+
124
+ // What a destination that has gone away is reported as, and a class rather than a message so
125
+ // the caller can tell it from this function's own refusal below without matching on words. The
126
+ // restore direction has to say different things about a command that stopped reading and a
127
+ // chunk file that came up short, and a check on the spelling of an error is a check that goes
128
+ // quietly wrong the first time a node release rewords one.
129
+ export class DestinationGoneError extends Error {
130
+ constructor(cause) {
131
+ super(
132
+ cause === null
133
+ ? 'The destination closed before the chunk was through.'
134
+ : `The destination stopped accepting bytes: ${cause.message}`,
135
+ { cause: cause ?? undefined },
136
+ )
137
+ this.name = 'DestinationGoneError'
138
+ }
139
+ }
140
+
141
+ // The other direction from ChunkReader, and hand-rolled for a reason of the same kind.
142
+ // `pipeline` was the obvious choice here and the wrong one: `{ end: false }` is what lets a
143
+ // command see one stream rather than one per chunk, and a destination that is never ended is a
144
+ // destination `pipeline` never cleans up after — its error, close, finish and end handlers stay
145
+ // on it for the life of the run. Measured on node 22: one handler per chunk on a PassThrough,
146
+ // four on a child's stdin, MaxListenersExceededWarnings torn through the progress bar by the
147
+ // eleventh chunk, and at MAX_CHUNKS around 40,000 closures on one emitter each retaining a
148
+ // finished pipeline's graph. So the loop is explicit, and every listener it adds it takes off
149
+ // again on the way out.
150
+ //
151
+ // What the loop has to keep, because the destination is a command's stdin and not a file:
152
+ // backpressure (a chunk can be 1800MB and must never sit whole in this process), a failure
153
+ // that rejects rather than waits forever, the handle left usable for the next chunk
154
+ // (`autoClose: false`, measured on node 22 rather than assumed), and the destination left open.
155
+ export async function writeChunkTo(writable, handle, length, { onProgress = () => {} } = {}) {
156
+ // `end: length - 1` is inclusive, so zero has to be turned away before it asks for byte -1.
157
+ if (length === 0) return
158
+
159
+ let seen = 0
160
+ let failure = null
161
+ let wake = null
162
+
163
+ // Latched for the whole pump rather than only while a drain is being waited for: a
164
+ // destination can fail in the middle of a write this loop is not waiting on, and a loop that
165
+ // noticed only at its next stall would go on reading into a pipe that has gone. `close`
166
+ // counts for as much as `error` — a command that leaves without a word closes its end and
167
+ // emits nothing else, and a drain that can never come is the hang this project refuses
168
+ // everywhere — so both of them wake the wait below as well as arming it.
169
+ const fail = (err) => {
170
+ failure ??= new DestinationGoneError(err ?? null)
171
+ if (wake) wake()
172
+ }
173
+
174
+ const onError = (err) => fail(err)
175
+ const onClose = () => fail(null)
176
+ const onDrain = () => {
177
+ if (wake) wake()
178
+ }
179
+
180
+ writable.on('error', onError)
181
+ writable.on('close', onClose)
182
+ writable.on('drain', onDrain)
183
+
184
+ // The failure to raise now: the one the listeners caught, or a destination that had already
185
+ // gone before this function was ever called. That second one cannot be listened for, because
186
+ // there is nothing left to hear — measured on node 22, `write()` into a destroyed or an
187
+ // already-ended stream returns false and emits nothing at all, node's `errorOrDestroy` bailing
188
+ // out on a stream that is already destroyed — so 'error', 'close' and 'drain' are all events
189
+ // that can no longer arrive and the wait below would be forever. The `pipeline` this replaced
190
+ // rejected on it; a command that takes one chunk and closes its end at the chunk boundary is
191
+ // how a restore gets here, and the `failure === null` guard on the wait does not help, because
192
+ // that covers a destination that died during a write rather than before one.
193
+ const failureNow = () => {
194
+ if (failure === null && (writable.destroyed || writable.writableEnded)) fail(null)
195
+
196
+ return failure
197
+ }
198
+ try {
199
+ // Inside the try, because it throws: `createReadStream` on a handle that is already closed
200
+ // fails synchronously (measured: ERR_OUT_OF_RANGE on fd -1), and opened above this line that
201
+ // would leave the three listeners on somebody's stdin — the exact leak this loop exists to
202
+ // have fixed. Not a 'data' listener on the stream either: attaching one switches it to
203
+ // flowing mode and the backpressure this function exists to honour goes with it. `for await`
204
+ // pulls instead, so nothing is read while a write is unacknowledged.
205
+ //
206
+ // Nothing destroys it by hand, which is measured rather than tidy. On node 22 a `destroy()`
207
+ // on a read stream opened this way closes the FileHandle under it whatever `autoClose` says
208
+ // — EBADF on the next read — and the next chunk would then ask a closed handle for a stream.
209
+ // What the two exits really do, on the same node: leaving the loop by a throw destroys the
210
+ // stream through the iterator's `return()`, while reaching the end of it does not destroy
211
+ // the stream at all — it is finished, holds no fd of its own, and the caller's handle is
212
+ // untouched either way.
213
+ const source = handle.createReadStream({ start: 0, end: length - 1, autoClose: false })
214
+
215
+ for await (const bytes of source) {
216
+ // At the top, because this is where a failure is raised: one that landed while the loop was
217
+ // waiting — for a read, or for the drain below — and one that was already there before the
218
+ // first read. Before the write and before the count, so a destination that has gone is
219
+ // handed nothing more: the bytes reported here are what the caller's running total and its
220
+ // failure message are built from, and one buffer more would be up to 64KB the message
221
+ // claims and the destination never took.
222
+ if (failureNow() !== null) throw failure
223
+
224
+ seen += bytes.length
225
+ onProgress(bytes.length)
226
+
227
+ // Handed over, not accepted: what this counts is what telstore wrote into the
228
+ // destination, which is as much as anything on this side can know. Whether the command
229
+ // on the far end ever read it is what its exit code answers.
230
+ //
231
+ // `failureNow()` is what stops the wait outliving the thing it is waiting for: a drain
232
+ // that has been overtaken by an error, a close, or a destination that was already gone is
233
+ // a drain that will never come, and waiting for it is the hang this project refuses
234
+ // everywhere. A failure that lands during the wait wakes it instead, and the check at the
235
+ // top of the next turn — or the one after the loop, on the last buffer — raises it.
236
+ if (!writable.write(bytes) && failureNow() === null) {
237
+ await new Promise((resolve) => {
238
+ wake = resolve
239
+ })
240
+ wake = null
241
+ }
242
+ }
243
+ } finally {
244
+ writable.off('error', onError)
245
+ writable.off('close', onClose)
246
+ writable.off('drain', onDrain)
247
+ }
248
+
249
+ // A failure that landed on the last write, after the loop had no more bytes to check it
250
+ // against. Accepted is not delivered: `write()` returning true means the destination took the
251
+ // bytes into its own buffer, and the EPIPE from a far end that has gone can arrive after that.
252
+ // Those bytes never reached the command, so this is not a chunk that went over.
253
+ if (failureNow() !== null) throw failure
254
+
255
+ // `createReadStream` stops at the file's real end of data without complaining when `end`
256
+ // reaches past it, so a chunk file shorter than `length` makes the loop above finish having
257
+ // moved too few bytes. That must not be read as success: the caller's running total is built
258
+ // from the size it was told to expect, so a short chunk here would become a truncated stream
259
+ // handed to somebody's tar and reported as a finished restore — the one thing this project
260
+ // refuses to do. This is the only failure this function raises on its own account; everything
261
+ // else that comes out of it belongs to the destination or to the file being read.
262
+ if (seen !== length) {
263
+ throw new Error(
264
+ `The chunk file held ${seen} bytes, but ${length} were asked for — the chunk is ` +
265
+ 'shorter than the manifest says it should be. Refusing to hand the destination a ' +
266
+ 'truncated stream and call it done.',
267
+ )
268
+ }
269
+ }
270
+
271
+ // The borrowing ends whether the chunk went out or the run fell over on it. close() failing
272
+ // must not be what stops the unlink — the file would sit there holding a whole chunk that
273
+ // nothing will ever remove — and a removal that fails must not replace the error already on
274
+ // its way out of the loop, so it is said on stderr rather than thrown.
275
+ //
276
+ // On writeErr rather than warn, like the prune report and for the same reason: a leaked file
277
+ // holding up to 1.8GB is not narration about a transfer that --silent asked to be spared. It
278
+ // is telstore leaving something on this machine that only the user can now clear up, and a
279
+ // caller silencing the progress bar has not asked to be kept in the dark about that.
280
+ export async function discardChunkFile(handle, file, { writeErr, chunkSize }) {
281
+ try {
282
+ await handle.close()
283
+ } catch {
284
+ // The file is about to be unlinked; whatever close had to say about it changes nothing.
285
+ }
286
+
287
+ try {
288
+ await fs.rm(file, { force: true })
289
+ } catch (err) {
290
+ writeErr(
291
+ `\nCould not remove the temporary chunk file ${file}: ${err.message}. It holds up to ` +
292
+ `${formatBytes(chunkSize)} and telstore will not try again — remove it by hand.\n`,
293
+ )
294
+ }
295
+ }
package/src/tar.js ADDED
@@ -0,0 +1,23 @@
1
+ // What the tar shortcuts know about names, and all they know. Pure strings: the parser needs
2
+ // it before anything is open and the restore command needs it before anything is downloaded,
3
+ // so it belongs to neither of them.
4
+ //
5
+ // `tarc` always compresses. A backup called a.tar holding gzip bytes would be a name that
6
+ // lies to whoever restores it, and the name is what `list` shows and `--search` matches, so
7
+ // it is the one part of this that cannot be left to the person typing.
8
+ const GZIP_SUFFIXES = ['.tar.gz', '.tgz']
9
+
10
+ export function isGzipName(name) {
11
+ const lower = String(name).toLowerCase()
12
+
13
+ return GZIP_SUFFIXES.some((suffix) => lower.endsWith(suffix))
14
+ }
15
+
16
+ // Appended in lower case, matched without case: A.TAR becomes A.TAR.gz rather than
17
+ // A.TAR.tar.gz, because the capitals are how someone typed it and not a second intention.
18
+ export function archiveName(name) {
19
+ if (isGzipName(name)) return name
20
+ if (String(name).toLowerCase().endsWith('.tar')) return `${name}.gz`
21
+
22
+ return `${name}.tar.gz`
23
+ }