telstore 0.1.6 → 0.1.8
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 +77 -8
- package/bin/telstore.js +34 -7
- package/package.json +1 -1
- package/src/caption.js +40 -2
- package/src/cli.js +105 -22
- package/src/commands/delete.js +230 -2
- package/src/commands/list.js +29 -5
- package/src/commands/restore.js +297 -50
- package/src/commands/status.js +80 -13
- package/src/commands/upload.js +255 -13
- package/src/downloader.js +4 -1
- package/src/manifest.js +21 -1
- package/src/progress.js +1 -0
- package/src/settings.js +6 -6
- package/src/sources.js +163 -0
- package/src/state.js +161 -45
package/src/state.js
CHANGED
|
@@ -16,9 +16,53 @@ export function stateFile(key, configDir = defaultConfigDir()) {
|
|
|
16
16
|
return path.join(stateDir(configDir), `${key}.json`)
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
// A restore's record is filed beside the uploads and must never compete with them for a
|
|
20
|
+
// prune slot. Losing an upload record strands chunks in a chat where only the id can still
|
|
21
|
+
// find them, which is why pruneStates reads each file back to name what it drops; losing a
|
|
22
|
+
// restore record costs one line of `status` for a .partial that still resumes perfectly.
|
|
23
|
+
// The name is what keeps them apart — an upload key is 40 hex characters and `r` is not
|
|
24
|
+
// hex, so the two namespaces cannot collide.
|
|
25
|
+
const RESTORE_PREFIX = 'restore-'
|
|
26
|
+
|
|
27
|
+
// stateKey's trick does not transfer: a .partial changes size and mtime on every write, so
|
|
28
|
+
// there is nothing there to key on. What holds still across runs is the backup being
|
|
29
|
+
// restored and the path being written.
|
|
30
|
+
export function restoreKey(backupId, absTarget) {
|
|
31
|
+
return createHash('sha1').update(`${backupId}:${absTarget}`).digest('hex')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function restoreFile(key, configDir = defaultConfigDir()) {
|
|
35
|
+
return path.join(stateDir(configDir), `${RESTORE_PREFIX}${key}.json`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// One reader for both kinds. A listing that forgets to filter hands `status` a restore
|
|
39
|
+
// record as though it were an upload, canResume stats a path that is not in it, and the
|
|
40
|
+
// report comes out wrong without anything failing — so there is one place that filters.
|
|
41
|
+
async function recordNames(configDir, restores) {
|
|
42
|
+
let names
|
|
43
|
+
try {
|
|
44
|
+
names = await fs.readdir(stateDir(configDir))
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err.code === 'ENOENT') return []
|
|
47
|
+
throw err
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return names.filter(
|
|
51
|
+
(name) => name.endsWith('.json') && name.startsWith(RESTORE_PREFIX) === restores,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function keyOfName(name) {
|
|
56
|
+
const base = name.slice(0, -'.json'.length)
|
|
57
|
+
|
|
58
|
+
return base.startsWith(RESTORE_PREFIX) ? base.slice(RESTORE_PREFIX.length) : base
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Why loadState returns null rather than throwing, in one place both kinds can use: one
|
|
62
|
+
// corrupt file must not hide the other records still waiting to be finished.
|
|
63
|
+
async function readRecord(file) {
|
|
20
64
|
try {
|
|
21
|
-
return JSON.parse(await fs.readFile(
|
|
65
|
+
return JSON.parse(await fs.readFile(file, 'utf8'))
|
|
22
66
|
} catch (err) {
|
|
23
67
|
if (err.code === 'ENOENT') return null
|
|
24
68
|
if (err instanceof SyntaxError) return null
|
|
@@ -26,6 +70,32 @@ export async function loadState(key, configDir = defaultConfigDir()) {
|
|
|
26
70
|
}
|
|
27
71
|
}
|
|
28
72
|
|
|
73
|
+
async function removeRecord(file) {
|
|
74
|
+
try {
|
|
75
|
+
await fs.unlink(file)
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (err.code !== 'ENOENT') throw err
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The mtime says when a record last made progress, which is the order `status` prints in —
|
|
82
|
+
// nothing decides from it whether a record exists. Its content has already been read by the
|
|
83
|
+
// time this runs, so a stat that fails must not drop the record or take the listing down:
|
|
84
|
+
// an unknown time sorts last and nothing is hidden. The file can genuinely vanish between
|
|
85
|
+
// the readdir and here, which is the case this exists for.
|
|
86
|
+
async function recordMtime(file) {
|
|
87
|
+
try {
|
|
88
|
+
const { mtimeMs } = await fs.stat(file)
|
|
89
|
+
return mtimeMs
|
|
90
|
+
} catch {
|
|
91
|
+
return 0
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function loadState(key, configDir = defaultConfigDir()) {
|
|
96
|
+
return await readRecord(stateFile(key, configDir))
|
|
97
|
+
}
|
|
98
|
+
|
|
29
99
|
export async function saveState(key, state, configDir = defaultConfigDir()) {
|
|
30
100
|
await writeJsonAtomic(stateFile(key, configDir), state)
|
|
31
101
|
}
|
|
@@ -37,11 +107,19 @@ export async function markChunkDone(key, state, i, entry, configDir = defaultCon
|
|
|
37
107
|
}
|
|
38
108
|
|
|
39
109
|
export async function clearState(key, configDir = defaultConfigDir()) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
110
|
+
await removeRecord(stateFile(key, configDir))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function loadRestore(key, configDir = defaultConfigDir()) {
|
|
114
|
+
return await readRecord(restoreFile(key, configDir))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function saveRestore(key, record, configDir = defaultConfigDir()) {
|
|
118
|
+
await writeJsonAtomic(restoreFile(key, configDir), record)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function clearRestore(key, configDir = defaultConfigDir()) {
|
|
122
|
+
await removeRecord(restoreFile(key, configDir))
|
|
45
123
|
}
|
|
46
124
|
|
|
47
125
|
// A state file is only useful while its backup can still be resumed, and nothing ever
|
|
@@ -54,24 +132,14 @@ export const MAX_STATES = 20
|
|
|
54
132
|
// were dropped: the caller says their ids out loud, because after this the id is the only
|
|
55
133
|
// way left to find those chunks in the chat.
|
|
56
134
|
export async function pruneStates(configDir = defaultConfigDir(), keep = MAX_STATES) {
|
|
57
|
-
let names
|
|
58
|
-
try {
|
|
59
|
-
names = await fs.readdir(stateDir(configDir))
|
|
60
|
-
} catch (err) {
|
|
61
|
-
if (err.code === 'ENOENT') return []
|
|
62
|
-
throw err
|
|
63
|
-
}
|
|
64
|
-
|
|
65
135
|
const files = []
|
|
66
136
|
|
|
67
|
-
for (const name of
|
|
68
|
-
if (!name.endsWith('.json')) continue
|
|
69
|
-
|
|
137
|
+
for (const name of await recordNames(configDir, false)) {
|
|
70
138
|
const file = path.join(stateDir(configDir), name)
|
|
71
139
|
|
|
72
140
|
try {
|
|
73
141
|
const stat = await fs.stat(file)
|
|
74
|
-
files.push({ key: name
|
|
142
|
+
files.push({ key: keyOfName(name), file, mtimeMs: stat.mtimeMs })
|
|
75
143
|
} catch (err) {
|
|
76
144
|
// Gone between readdir and stat: nothing left to prune.
|
|
77
145
|
if (err.code !== 'ENOENT') throw err
|
|
@@ -99,25 +167,19 @@ export async function pruneStates(configDir = defaultConfigDir(), keep = MAX_STA
|
|
|
99
167
|
//
|
|
100
168
|
// The key comes back alongside each record because canResume needs it, and the file name is
|
|
101
169
|
// the only place it survives: the record's own path, size and mtime are exactly what a
|
|
102
|
-
// rewritten file makes stale, so recomputing the key from them would always say yes.
|
|
170
|
+
// rewritten file makes stale, so recomputing the key from them would always say yes. The
|
|
171
|
+
// mtime comes back because it is when this backup last made progress, which is the order
|
|
172
|
+
// status prints records in.
|
|
103
173
|
export async function listStates(configDir = defaultConfigDir()) {
|
|
104
|
-
let names
|
|
105
|
-
try {
|
|
106
|
-
names = await fs.readdir(stateDir(configDir))
|
|
107
|
-
} catch (err) {
|
|
108
|
-
if (err.code === 'ENOENT') return []
|
|
109
|
-
throw err
|
|
110
|
-
}
|
|
111
|
-
|
|
112
174
|
const states = []
|
|
113
175
|
|
|
114
|
-
for (const name of
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const key = name.slice(0, -'.json'.length)
|
|
176
|
+
for (const name of await recordNames(configDir, false)) {
|
|
177
|
+
const key = keyOfName(name)
|
|
118
178
|
const state = await loadState(key, configDir)
|
|
119
179
|
|
|
120
|
-
if (state)
|
|
180
|
+
if (!state) continue
|
|
181
|
+
|
|
182
|
+
states.push({ key, state, mtimeMs: await recordMtime(path.join(stateDir(configDir), name)) })
|
|
121
183
|
}
|
|
122
184
|
|
|
123
185
|
return states
|
|
@@ -134,20 +196,10 @@ export async function listStates(configDir = defaultConfigDir()) {
|
|
|
134
196
|
// cannot know which to drop, and that is the caller's decision to refuse, not ours to make
|
|
135
197
|
// by picking one.
|
|
136
198
|
export async function findStates(backupId, configDir = defaultConfigDir()) {
|
|
137
|
-
let names
|
|
138
|
-
try {
|
|
139
|
-
names = await fs.readdir(stateDir(configDir))
|
|
140
|
-
} catch (err) {
|
|
141
|
-
if (err.code === 'ENOENT') return []
|
|
142
|
-
throw err
|
|
143
|
-
}
|
|
144
|
-
|
|
145
199
|
const found = []
|
|
146
200
|
|
|
147
|
-
for (const name of
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const key = name.slice(0, -'.json'.length)
|
|
201
|
+
for (const name of await recordNames(configDir, false)) {
|
|
202
|
+
const key = keyOfName(name)
|
|
151
203
|
const state = await loadState(key, configDir)
|
|
152
204
|
|
|
153
205
|
if (state?.id === backupId) found.push({ key, file: stateFile(key, configDir), state })
|
|
@@ -180,3 +232,67 @@ export async function canResume(key, state) {
|
|
|
180
232
|
|
|
181
233
|
return { ok: true }
|
|
182
234
|
}
|
|
235
|
+
|
|
236
|
+
export const MAX_RESTORES = 20
|
|
237
|
+
|
|
238
|
+
// A record with no id or no target can neither be printed nor resumed from, so status has
|
|
239
|
+
// nothing to do with it. Skipped rather than rendered with blanks: these files are
|
|
240
|
+
// hand-editable, and a row that names nothing is worse than no row.
|
|
241
|
+
export async function listRestores(configDir = defaultConfigDir()) {
|
|
242
|
+
const restores = []
|
|
243
|
+
|
|
244
|
+
for (const name of await recordNames(configDir, true)) {
|
|
245
|
+
const key = keyOfName(name)
|
|
246
|
+
const record = await loadRestore(key, configDir)
|
|
247
|
+
|
|
248
|
+
if (typeof record?.id !== 'string' || typeof record?.target !== 'string') continue
|
|
249
|
+
|
|
250
|
+
restores.push({ key, record, mtimeMs: await recordMtime(path.join(stateDir(configDir), name)) })
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return restores
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Unlike pruneStates this returns nothing and reads nothing back. Dropping a restore record
|
|
257
|
+
// strands no data — the .partial it points at still resumes, because the evidence for a
|
|
258
|
+
// resume was never in the record — so there is nothing to announce and no reason to open
|
|
259
|
+
// each file just to name it. It removes the signpost, never the .partial: a multi-gigabyte
|
|
260
|
+
// file must not disappear as a side effect of starting an unrelated restore.
|
|
261
|
+
export async function pruneRestores(configDir = defaultConfigDir(), keep = MAX_RESTORES) {
|
|
262
|
+
const files = []
|
|
263
|
+
|
|
264
|
+
for (const name of await recordNames(configDir, true)) {
|
|
265
|
+
const file = path.join(stateDir(configDir), name)
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const stat = await fs.stat(file)
|
|
269
|
+
files.push({ file, mtimeMs: stat.mtimeMs })
|
|
270
|
+
} catch (err) {
|
|
271
|
+
if (err.code !== 'ENOENT') throw err
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
files.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
276
|
+
|
|
277
|
+
for (const { file } of files.slice(keep)) await removeRecord(file)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// What findStates is for uploads, and for the same reason: the file name hashes the target
|
|
281
|
+
// path, which delete does not know — it has an id and nothing else. Matching the id inside
|
|
282
|
+
// each file is the only way that cannot point at the wrong one.
|
|
283
|
+
//
|
|
284
|
+
// Every record claiming the id comes back, not the first. One backup restored to two places
|
|
285
|
+
// is two records, and a delete that drops one of them leaves a signpost to chunks that are
|
|
286
|
+
// no longer in the chat.
|
|
287
|
+
export async function findRestores(backupId, configDir = defaultConfigDir()) {
|
|
288
|
+
const found = []
|
|
289
|
+
|
|
290
|
+
for (const name of await recordNames(configDir, true)) {
|
|
291
|
+
const key = keyOfName(name)
|
|
292
|
+
const record = await loadRestore(key, configDir)
|
|
293
|
+
|
|
294
|
+
if (record?.id === backupId) found.push({ key, file: restoreFile(key, configDir), record })
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return found
|
|
298
|
+
}
|