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/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
- export async function loadState(key, configDir = defaultConfigDir()) {
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(stateFile(key, configDir), 'utf8'))
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
- try {
41
- await fs.unlink(stateFile(key, configDir))
42
- } catch (err) {
43
- if (err.code !== 'ENOENT') throw err
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 names) {
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.slice(0, -'.json'.length), file, mtimeMs: stat.mtimeMs })
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 names) {
115
- if (!name.endsWith('.json')) continue
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) states.push({ key, 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 names) {
148
- if (!name.endsWith('.json')) continue
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
+ }