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.
@@ -0,0 +1,294 @@
1
+ import { chatName, describeChat } from '../chat.js'
2
+ import {
3
+ MESSAGE_BATCH_SIZE,
4
+ closeQuietly,
5
+ connect as realConnect,
6
+ documentFileName,
7
+ documentSize,
8
+ findManifestMessage,
9
+ getDocuments as realGetDocuments,
10
+ readMessageBytes as realReadMessageBytes,
11
+ } from '../client.js'
12
+ import { configFile, defaultConfigDir, loadConfig } from '../config.js'
13
+ import { chunkFileName, manifestFileName, parseManifest } from '../manifest.js'
14
+ import { formatBytes, formatDuration, plural } from '../progress.js'
15
+ import { assertLoggedIn } from '../session.js'
16
+ import { requireChat, resolveSettings } from '../settings.js'
17
+
18
+ // parseManifest guarantees every number in a manifest, but not the file name — it is
19
+ // decoration, and nothing verifies differently because of it. It is still text off a chat.
20
+ function describeName(name) {
21
+ return typeof name === 'string' && name.trim() !== '' ? name : '—'
22
+ }
23
+
24
+ // What is wrong with one chunk, or null when nothing is. The first failing check wins: a
25
+ // chunk is damaged or it is not, and listing three complaints about one message would make
26
+ // "2 damaged" mean something other than two chunks.
27
+ //
28
+ // Every question here is one the chat can answer without sending a byte of the file. What
29
+ // this cannot ask is whether the bytes inside are the bytes that went up — only downloading
30
+ // them answers that, which is why the closing line says so rather than leaving it implied.
31
+ function inspect(chunk, message, { backupId, total, chat }) {
32
+ const at = `Chunk ${chunk.i + 1}/${total}`
33
+
34
+ if (!message) {
35
+ return `${at} is gone: message ${chunk.msgId} is no longer in ${chatName(chat)}.`
36
+ }
37
+
38
+ const size = documentSize(message)
39
+
40
+ if (size === null) {
41
+ return `${at} is message ${chunk.msgId}, which has no file attached.`
42
+ }
43
+
44
+ const wanted = chunkFileName(backupId, chunk.i)
45
+ const fileName = documentFileName(message)
46
+
47
+ if (fileName !== wanted) {
48
+ return fileName === null
49
+ ? `${at} is message ${chunk.msgId}, whose file carries no name; the manifest expects ` +
50
+ `"${wanted}".`
51
+ : `${at} is message ${chunk.msgId}, which carries the file name ` +
52
+ `${JSON.stringify(fileName)} rather than "${wanted}".`
53
+ }
54
+
55
+ if (size !== chunk.size) {
56
+ return `${at} is ${size} bytes in the chat, the manifest records ${chunk.size}.`
57
+ }
58
+
59
+ return null
60
+ }
61
+
62
+ export async function runVerify(backupId, options = {}, deps = {}) {
63
+ const {
64
+ connect = realConnect,
65
+ disconnect = (client) => client.destroy(),
66
+ configDir = defaultConfigDir(),
67
+ searchManifest = findManifestMessage,
68
+ readMessageBytes = realReadMessageBytes,
69
+ getDocuments = realGetDocuments,
70
+ retryOptions = {},
71
+ writeErr = (line) => process.stderr.write(line),
72
+ log: writeLog = (line) => console.log(line),
73
+ silent = false,
74
+ } = deps
75
+
76
+ const config = await loadConfig(configDir)
77
+ const { values: settings } = resolveSettings(options, config, { file: configFile(configDir) })
78
+ // Ask about the login before the destination, as list does: telling someone who has never
79
+ // logged in to pick a chat sends them off after the wrong thing.
80
+ assertLoggedIn(config)
81
+ const chat = requireChat(settings)
82
+
83
+ const log = silent ? () => {} : writeLog
84
+ const warn = silent ? () => {} : writeErr
85
+
86
+ // Upload and restore stay quiet until the third retry so a handful of -503s do not bury
87
+ // the progress bar. There is no bar here to bury — this command sends a handful of small
88
+ // requests and prints one verdict — so a wait long enough to notice is announced at once.
89
+ function onRetry(err, attempt, delayMs) {
90
+ warn(
91
+ `\nTemporary error (${err.message}), retry ${attempt} in ` +
92
+ `${formatDuration(delayMs / 1000)}.\n`,
93
+ )
94
+ }
95
+
96
+ const client = await connect(config, { verbose: settings.verbose })
97
+
98
+ try {
99
+ const manifestMessage = await searchManifest(client, chat, backupId)
100
+
101
+ if (!manifestMessage) {
102
+ throw new Error(
103
+ `No backup ${backupId} found in ${chatName(chat)}. Check the id with ` +
104
+ '"npx telstore list", or use --chat to point at the right chat.',
105
+ )
106
+ }
107
+
108
+ // The full layout checks, not the lenient path delete takes. delete reads a manifest to
109
+ // destroy what it names, so a broken one is exactly what somebody is there to remove;
110
+ // verify reads it to answer whether restore would work, and restore would refuse this
111
+ // one. Saying so in parseManifest's own words keeps one description of one fault.
112
+ const manifest = parseManifest(await readMessageBytes(client, manifestMessage))
113
+
114
+ // The same refusal delete makes, for the same reason from the other side: the manifest
115
+ // was found by the file name telstore wrote, so a body naming another backup is a file
116
+ // that was renamed or replaced, and its message ids describe somebody else's chunks.
117
+ // Reporting those as this backup's health is the one wrong answer this command can give.
118
+ if (manifest.id !== undefined && manifest.id !== backupId) {
119
+ throw new Error(
120
+ `The manifest named ${manifestFileName(backupId)} describes backup ` +
121
+ `${JSON.stringify(manifest.id)}, not ${backupId}. Its message ids point at another ` +
122
+ `backup's chunks, so telstore cannot say whether ${backupId} is still there.`,
123
+ )
124
+ }
125
+
126
+ const total = manifest.chunks.length
127
+
128
+ log(`Backup ${backupId}`)
129
+ log(
130
+ `File ${describeName(manifest.name)} ` +
131
+ `(${formatBytes(manifest.size)}, ${plural(total, 'chunk')})`,
132
+ )
133
+ log(`In ${describeChat(chat)}`)
134
+ log('')
135
+
136
+ // A backup at the 10000-chunk ceiling is a hundred requests, sent one at a time. Silence
137
+ // for that long reads as a hang, which is the one thing no wait in this project may look
138
+ // like — and below a single request there is no progress worth a line.
139
+ const loud = total > MESSAGE_BATCH_SIZE
140
+
141
+ const found = await getDocuments(
142
+ client,
143
+ chat,
144
+ manifest.chunks.map((chunk) => chunk.msgId),
145
+ {
146
+ retryOptions: { ...retryOptions, onRetry },
147
+ onBatch: (done, all) => {
148
+ if (loud) warn(`\rChecking chunk messages ${done}/${all}…`)
149
+ },
150
+ },
151
+ )
152
+
153
+ if (loud) warn('\n')
154
+
155
+ const damaged = []
156
+
157
+ for (const chunk of manifest.chunks) {
158
+ const fault = inspect(chunk, found.get(chunk.msgId), { backupId, total, chat })
159
+
160
+ if (fault) {
161
+ damaged.push(fault)
162
+ log(fault)
163
+ }
164
+ }
165
+
166
+ if (damaged.length > 0) {
167
+ log('')
168
+ log(
169
+ `${plural(total, 'chunk')} checked, ${damaged.length} damaged. ` +
170
+ 'This backup cannot be restored.',
171
+ )
172
+ } else {
173
+ log(
174
+ `${plural(total, 'chunk')} present, at the ` +
175
+ `${total === 1 ? 'size' : 'sizes'} the manifest records.`,
176
+ )
177
+ log('This does not download them, so it cannot prove their contents.')
178
+ log('')
179
+ log(`Restore with: npx telstore restore ${backupId}`)
180
+ }
181
+
182
+ return { id: backupId, name: manifest.name, chunks: total, damaged }
183
+ } finally {
184
+ await closeQuietly(client, disconnect)
185
+ }
186
+ }
187
+
188
+ // A backup is a failure whether telstore could not look it up or looked and found it broken.
189
+ // The command worked either way — but "did the run find everything it was asked to check"
190
+ // is the question the exit code answers, and both answers to that are no.
191
+ function isFailure(result) {
192
+ return Boolean(result.error) || result.damaged.length > 0
193
+ }
194
+
195
+ // The same shape as runDeletes and runRestores, minus the question: verify removes nothing,
196
+ // so there is nothing to authorise. What is knowable before the connection is refused up
197
+ // front; what only the chat can answer is per id, named when it happens and again at the end.
198
+ export async function runVerifies(backupIds, options = {}, deps = {}) {
199
+ const {
200
+ connect = realConnect,
201
+ disconnect = (client) => client.destroy(),
202
+ configDir = defaultConfigDir(),
203
+ writeErr = (line) => process.stderr.write(line),
204
+ log: writeLog = (line) => console.log(line),
205
+ silent = false,
206
+ } = deps
207
+
208
+ // One id keeps its own wording and its own thrown error. A summary about one backup only
209
+ // repeats the lines above it.
210
+ if (backupIds.length === 1) {
211
+ const result = await runVerify(backupIds[0], options, deps)
212
+ return { results: [result], failed: isFailure(result) ? 1 : 0 }
213
+ }
214
+
215
+ const duplicate = backupIds.find((id, index) => backupIds.indexOf(id) !== index)
216
+
217
+ if (duplicate) {
218
+ throw new Error(
219
+ `${duplicate} is named twice. Checking one backup twice asks the chat the same ` +
220
+ 'question again — name it once.',
221
+ )
222
+ }
223
+
224
+ const config = await loadConfig(configDir)
225
+
226
+ assertLoggedIn(config)
227
+
228
+ const { values: settings } = resolveSettings(options, config, { file: configFile(configDir) })
229
+ const chat = requireChat(settings)
230
+
231
+ const log = silent ? () => {} : writeLog
232
+ const warn = silent ? () => {} : writeErr
233
+
234
+ let shared = null
235
+ const perId = {
236
+ ...deps,
237
+ connect: async (theirConfig, connectOptions) =>
238
+ (shared ??= await connect(theirConfig, connectOptions)),
239
+ disconnect: async () => {},
240
+ }
241
+
242
+ const results = []
243
+
244
+ try {
245
+ await perId.connect(config, { verbose: settings.verbose })
246
+
247
+ for (const [index, backupId] of backupIds.entries()) {
248
+ if (index > 0) log('')
249
+ log(`[${index + 1}/${backupIds.length}] ${backupId}`)
250
+
251
+ try {
252
+ results.push(await runVerify(backupId, options, perId))
253
+ } catch (err) {
254
+ // Unlike delete, an id nothing knows about does not stop the run: nothing here is
255
+ // destroyed, and the other ids are exactly the ones somebody is checking on.
256
+ results.push({ id: backupId, error: err.message, damaged: [] })
257
+ warn(`\n${backupId} failed: ${err.message}\n`)
258
+ }
259
+ }
260
+ } finally {
261
+ if (shared) {
262
+ await closeQuietly(shared, disconnect, (err) =>
263
+ warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`),
264
+ )
265
+ }
266
+ }
267
+
268
+ const failed = results.filter(isFailure).length
269
+
270
+ log('')
271
+ for (const line of summaryLines(results, failed)) log(line)
272
+
273
+ return { results, failed }
274
+ }
275
+
276
+ // Every id gets a line whether it checked out or not: one missing from this list would be a
277
+ // backup nobody could tell the state of, which is the whole reason this command exists.
278
+ function summaryLines(results, failed) {
279
+ const width = Math.max(...results.map((result) => result.id.length))
280
+
281
+ return [
282
+ `${results.length} backups: ${results.length - failed} verified, ${failed} failed.`,
283
+ '',
284
+ ...results.map((result) => {
285
+ const id = result.id.padEnd(width)
286
+
287
+ if (result.error) return ` ${id} failed: ${result.error}`
288
+
289
+ return result.damaged.length > 0
290
+ ? ` ${id} ${plural(result.damaged.length, 'chunk')} damaged`
291
+ : ` ${id} ${plural(result.chunks, 'chunk')} present`
292
+ }),
293
+ ]
294
+ }
package/src/manifest.js CHANGED
@@ -11,12 +11,69 @@ export function newBackupId(now = new Date(), randomHex = () => randomBytes(3).t
11
11
  return `telstore-${yyyy}${mm}${dd}-${randomHex()}`
12
12
  }
13
13
 
14
+ // The day a backup id carries, as the UTC second that day began. newBackupId stamps it above
15
+ // from the clock of the machine making the backup, so it is that machine's idea of the day
16
+ // rather than Telegram's — which is why the one reader of this (delete's walk of the chat)
17
+ // gives it a day of slack and only ever uses it as a floor.
18
+ //
19
+ // A date that does not exist is not a day: `telstore-20269999-abc` would otherwise roll over
20
+ // into a year's time and read as a floor above everything in the chat, which is an early stop
21
+ // nobody would see. Null instead, and the caller falls back to a bound it can prove.
22
+ const BACKUP_ID_DAY = /^telstore-(\d{4})(\d{2})(\d{2})-[0-9a-f]+$/
23
+
24
+ export function backupIdDay(id) {
25
+ const match = BACKUP_ID_DAY.exec(String(id))
26
+
27
+ if (!match) return null
28
+
29
+ const [year, month, day] = match.slice(1).map(Number)
30
+ const at = new Date(Date.UTC(year, month - 1, day))
31
+
32
+ if (at.getUTCFullYear() !== year || at.getUTCMonth() !== month - 1 || at.getUTCDate() !== day) {
33
+ return null
34
+ }
35
+
36
+ return Math.floor(at.getTime() / 1000)
37
+ }
38
+
39
+ // The infix in every chunk's file name. A constant rather than a literal for the same reason
40
+ // MANIFEST_SUFFIX is one: there are two readers of that name now — the writer below and
41
+ // isChunkFileName — and a reader that disagrees with the writer by one character finds
42
+ // nothing at all.
43
+ const CHUNK_INFIX = '.part'
44
+
14
45
  export function chunkFileName(id, i) {
15
- return `${id}.part${String(i + 1).padStart(4, '0')}`
46
+ return `${id}${CHUNK_INFIX}${String(i + 1).padStart(4, '0')}`
16
47
  }
17
48
 
49
+ // Whether a document in a chat is a chunk of this backup, decided by the file name telstore
50
+ // itself wrote and not by the caption beside it — the rule findManifestMessage already keeps,
51
+ // for the same reason: a caption is text a person can edit and a file name is not.
52
+ //
53
+ // The number is checked but never read back. What the caller needs is which backup a document
54
+ // belongs to, and a chunk whose index says something impossible is still that backup's chunk.
55
+ // What the check is for is the other direction: without it `<id>.partial` or `<id>.part.bak`
56
+ // — names telstore never writes, but names a person can give a file they upload themselves —
57
+ // would be read as chunks of a backup and destroyed along with it.
58
+ export function isChunkFileName(id, fileName) {
59
+ if (typeof fileName !== 'string') return false
60
+
61
+ const prefix = `${id}${CHUNK_INFIX}`
62
+
63
+ if (!fileName.startsWith(prefix)) return false
64
+
65
+ const number = fileName.slice(prefix.length)
66
+
67
+ return number.length > 0 && /^[0-9]+$/.test(number)
68
+ }
69
+
70
+ // The suffix telstore has written on every manifest since version 1, and what `list` picks
71
+ // a manifest out of a chat by. One definition, because a reader that disagrees with the
72
+ // writer by one character finds nothing at all.
73
+ export const MANIFEST_SUFFIX = '.manifest.json'
74
+
18
75
  export function manifestFileName(id) {
19
- return `${id}.manifest.json`
76
+ return `${id}${MANIFEST_SUFFIX}`
20
77
  }
21
78
 
22
79
  export function buildManifest({
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 {