data-ark 0.1.1 → 0.1.3
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 +45 -3
- package/bin/data-ark.js +25 -16
- package/package.json +1 -1
- package/src/caption.js +63 -0
- package/src/chunking.js +6 -0
- package/src/cli.js +65 -3
- package/src/client.js +78 -2
- package/src/commands/list.js +136 -0
- package/src/commands/login.js +25 -7
- package/src/commands/restore.js +41 -35
- package/src/commands/set-destination.js +15 -0
- package/src/commands/status.js +93 -0
- package/src/commands/upload.js +77 -29
- package/src/config.js +9 -4
- package/src/downloader.js +42 -6
- package/src/manifest.js +3 -1
- package/src/progress.js +7 -1
- package/src/state.js +76 -10
- package/src/uploader.js +4 -1
package/src/state.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'
|
|
|
2
2
|
import { promises as fs } from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
|
|
5
|
-
import { defaultConfigDir } from './config.js'
|
|
5
|
+
import { defaultConfigDir, writeJsonAtomic } from './config.js'
|
|
6
6
|
|
|
7
7
|
export function stateDir(configDir = defaultConfigDir()) {
|
|
8
8
|
return path.join(configDir, 'state')
|
|
@@ -12,7 +12,7 @@ export function stateKey(absPath, size, mtimeMs) {
|
|
|
12
12
|
return createHash('sha1').update(`${absPath}:${size}:${mtimeMs}`).digest('hex')
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
function stateFile(key, configDir) {
|
|
15
|
+
export function stateFile(key, configDir = defaultConfigDir()) {
|
|
16
16
|
return path.join(stateDir(configDir), `${key}.json`)
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -27,14 +27,7 @@ export async function loadState(key, configDir = defaultConfigDir()) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export async function saveState(key, state, configDir = defaultConfigDir()) {
|
|
30
|
-
|
|
31
|
-
await fs.mkdir(dir, { recursive: true, mode: 0o700 })
|
|
32
|
-
|
|
33
|
-
const file = stateFile(key, configDir)
|
|
34
|
-
const tmp = `${file}.tmp`
|
|
35
|
-
|
|
36
|
-
await fs.writeFile(tmp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 })
|
|
37
|
-
await fs.rename(tmp, file)
|
|
30
|
+
await writeJsonAtomic(stateFile(key, configDir), state)
|
|
38
31
|
}
|
|
39
32
|
|
|
40
33
|
export async function markChunkDone(key, state, i, entry, configDir = defaultConfigDir()) {
|
|
@@ -50,3 +43,76 @@ export async function clearState(key, configDir = defaultConfigDir()) {
|
|
|
50
43
|
if (err.code !== 'ENOENT') throw err
|
|
51
44
|
}
|
|
52
45
|
}
|
|
46
|
+
|
|
47
|
+
// A state file is only useful while its backup can still be resumed, and nothing ever
|
|
48
|
+
// removes one whose file was edited since: the key includes mtime, so that state can never
|
|
49
|
+
// match again. Left alone the directory only grows, and with it the report `status` prints.
|
|
50
|
+
export const MAX_STATES = 20
|
|
51
|
+
|
|
52
|
+
// The newest states are the ones worth keeping, and a state file is rewritten every time a
|
|
53
|
+
// chunk lands, so its mtime is when this backup last made progress. Returns the states that
|
|
54
|
+
// were dropped: the caller says their ids out loud, because after this the id is the only
|
|
55
|
+
// way left to find those chunks in the chat.
|
|
56
|
+
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
|
+
const files = []
|
|
66
|
+
|
|
67
|
+
for (const name of names) {
|
|
68
|
+
if (!name.endsWith('.json')) continue
|
|
69
|
+
|
|
70
|
+
const file = path.join(stateDir(configDir), name)
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const stat = await fs.stat(file)
|
|
74
|
+
files.push({ key: name.slice(0, -'.json'.length), file, mtimeMs: stat.mtimeMs })
|
|
75
|
+
} catch (err) {
|
|
76
|
+
// Gone between readdir and stat: nothing left to prune.
|
|
77
|
+
if (err.code !== 'ENOENT') throw err
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
files.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
82
|
+
|
|
83
|
+
const dropped = []
|
|
84
|
+
|
|
85
|
+
for (const { key, file } of files.slice(keep)) {
|
|
86
|
+
// Read before unlink: a file that cannot be read back, or that carries no id, is
|
|
87
|
+
// still pruned — it just cannot be named, and a report naming nothing helps no one.
|
|
88
|
+
const state = await loadState(key, configDir)
|
|
89
|
+
await fs.unlink(file)
|
|
90
|
+
if (state?.id) dropped.push(state)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return dropped
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// status needs every unfinished backup at once. A state file that cannot be read is skipped
|
|
97
|
+
// rather than fatal, for the same reason loadState returns null: one corrupt file must not
|
|
98
|
+
// hide the other backups still waiting to be finished.
|
|
99
|
+
export async function listStates(configDir = defaultConfigDir()) {
|
|
100
|
+
let names
|
|
101
|
+
try {
|
|
102
|
+
names = await fs.readdir(stateDir(configDir))
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (err.code === 'ENOENT') return []
|
|
105
|
+
throw err
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const states = []
|
|
109
|
+
|
|
110
|
+
for (const name of names) {
|
|
111
|
+
if (!name.endsWith('.json')) continue
|
|
112
|
+
|
|
113
|
+
const state = await loadState(name.slice(0, -'.json'.length), configDir)
|
|
114
|
+
if (state) states.push(state)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return states
|
|
118
|
+
}
|
package/src/uploader.js
CHANGED
|
@@ -16,7 +16,10 @@ const read = promisify(readCallback)
|
|
|
16
16
|
export const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024
|
|
17
17
|
|
|
18
18
|
async function readExactly(fd, length, position) {
|
|
19
|
-
|
|
19
|
+
// allocUnsafe skips zero-filling 512KB per part — about 0.4s of memset per 1800MB chunk,
|
|
20
|
+
// on the same thread that drives the in-flight requests. Safe only because the loop below
|
|
21
|
+
// either fills every byte or throws: no uninitialised byte can reach a request or the hash.
|
|
22
|
+
const buffer = Buffer.allocUnsafe(length)
|
|
20
23
|
let filled = 0
|
|
21
24
|
|
|
22
25
|
while (filled < length) {
|