nukebase 2.3.0 → 2.5.0
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 +7 -2
- package/bin/nukebase.js +41 -17
- package/lib/account-commands.js +633 -627
- package/lib/guide.js +3 -1
- package/lib/project-commands.js +2837 -2601
- package/lib/storage.js +69 -31
- package/package.json +1 -1
package/lib/storage.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
// Object storage for `nukebase
|
|
2
|
-
// backup
|
|
3
|
-
// with setFile) in a bucket instead of server/files.
|
|
4
|
-
// this machine
|
|
5
|
-
// one hour
|
|
6
|
-
//
|
|
1
|
+
// Object storage for `nukebase pull --data` and `nukebase push --data` (the
|
|
2
|
+
// older `backup --storage` and `push --include backup`). A project with object
|
|
3
|
+
// storage keeps its files (what the app stores with setFile) in a bucket instead of server/files. Uploads go straight from
|
|
4
|
+
// this machine to the bucket through links the server signs for one file and
|
|
5
|
+
// one hour. Downloads come through the server, on links it signs the same way:
|
|
6
|
+
// the storage lets only the project's own key read what the app wrote, and
|
|
7
|
+
// that key works only from the server. Locally they
|
|
8
|
+
// live where a project without storage keeps them: storage/<path> in projects
|
|
9
|
+
// made from the engine 1.12 template (marked by storage/$nukebase),
|
|
10
|
+
// server/files/<path> before.
|
|
7
11
|
//
|
|
8
12
|
// Every transfer is IPv4: the storage provider stalls parallel uploads over
|
|
9
13
|
// IPv6 (measured from the server: 13 of 18 stalled, against none over IPv4).
|
|
@@ -14,6 +18,12 @@ const { join, dirname, relative, sep } = require('path');
|
|
|
14
18
|
const { pipeline } = require('stream/promises');
|
|
15
19
|
|
|
16
20
|
const FILES_DIR = 'server/files';
|
|
21
|
+
// The project's file folder, relative to root: the engine and the server pick
|
|
22
|
+
// the same way.
|
|
23
|
+
function filesDir(root) {
|
|
24
|
+
try { if (fs.statSync(join(root, 'storage', '$nukebase')).isFile()) return 'storage'; } catch {}
|
|
25
|
+
return FILES_DIR;
|
|
26
|
+
}
|
|
17
27
|
const IDLE_MS = 30000;
|
|
18
28
|
const TRIES = 4;
|
|
19
29
|
const PARALLEL = 6;
|
|
@@ -90,43 +100,55 @@ async function links(API, authHeaders, keys) {
|
|
|
90
100
|
return out;
|
|
91
101
|
}
|
|
92
102
|
|
|
93
|
-
// Which bucket files differ from the local copy
|
|
94
|
-
//
|
|
95
|
-
// mtime to the bucket's
|
|
103
|
+
// Which bucket files differ from the local copy in the file folder: by size,
|
|
104
|
+
// or by being newer in the bucket than here by more than a second. A download
|
|
105
|
+
// sets the local mtime to the bucket's, and an upload sets it to when the
|
|
106
|
+
// bucket got it, so neither is fetched again.
|
|
96
107
|
function changedRemote(dest, files) {
|
|
108
|
+
const dir = filesDir(dest);
|
|
97
109
|
return Object.keys(files).filter((key) => {
|
|
98
|
-
const local = join(dest,
|
|
110
|
+
const local = join(dest, dir, ...key.split('/'));
|
|
99
111
|
let st;
|
|
100
112
|
try { st = fs.statSync(local); } catch { return true; }
|
|
101
113
|
const [size, mtime] = files[key];
|
|
102
|
-
return st.size !== size || Math.
|
|
114
|
+
return st.size !== size || mtime > Math.floor(st.mtimeMs / 1000) + 1;
|
|
103
115
|
});
|
|
104
116
|
}
|
|
105
117
|
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
118
|
+
// One URL into `target`, through a .part renamed when complete, so an
|
|
119
|
+
// interrupted run leaves no torn file.
|
|
120
|
+
async function fetchTo(what, url, target, counter) {
|
|
121
|
+
fs.mkdirSync(dirname(target), { recursive: true });
|
|
122
|
+
const part = target + '.part';
|
|
123
|
+
try {
|
|
124
|
+
await retry(what, async () => {
|
|
125
|
+
const r = await request('GET', url);
|
|
126
|
+
if (r.status !== 200) { r.stream.resume(); throw new Error(`HTTP ${r.status}`); }
|
|
127
|
+
await pipeline(r.stream, counter(), fs.createWriteStream(part));
|
|
128
|
+
});
|
|
129
|
+
fs.renameSync(part, target);
|
|
130
|
+
} catch (e) {
|
|
131
|
+
try { fs.unlinkSync(part); } catch {}
|
|
132
|
+
throw e;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Download `keys` into the file folder under dest. counter() is a
|
|
137
|
+
// pass-through that counts bytes for the progress line.
|
|
109
138
|
async function download(API, authHeaders, dest, files, keys, counter) {
|
|
110
139
|
const failed = [];
|
|
140
|
+
const dir = filesDir(dest);
|
|
111
141
|
for (let i = 0; i < keys.length; i += 500) {
|
|
112
142
|
const batch = keys.slice(i, i + 500);
|
|
113
143
|
const urls = await links(API, authHeaders, batch); // an hour each: fetched per batch
|
|
114
144
|
await pool(batch, PARALLEL, async (key) => {
|
|
115
|
-
const target = join(dest,
|
|
116
|
-
if (!target.startsWith(join(dest,
|
|
117
|
-
fs.mkdirSync(dirname(target), { recursive: true });
|
|
118
|
-
const part = target + '.part';
|
|
145
|
+
const target = join(dest, dir, ...key.split('/'));
|
|
146
|
+
if (!target.startsWith(join(dest, dir) + sep)) return;
|
|
119
147
|
try {
|
|
120
|
-
await
|
|
121
|
-
const r = await request('GET', urls[key]);
|
|
122
|
-
if (r.status !== 200) { r.stream.resume(); throw new Error(`HTTP ${r.status}`); }
|
|
123
|
-
await pipeline(r.stream, counter(), fs.createWriteStream(part));
|
|
124
|
-
});
|
|
125
|
-
fs.renameSync(part, target);
|
|
148
|
+
await fetchTo(key, urls[key], target, counter);
|
|
126
149
|
const mtime = files[key][1];
|
|
127
150
|
if (mtime) fs.utimesSync(target, mtime, mtime);
|
|
128
151
|
} catch (e) {
|
|
129
|
-
try { fs.unlinkSync(part); } catch {}
|
|
130
152
|
failed.push(e.message);
|
|
131
153
|
}
|
|
132
154
|
});
|
|
@@ -134,11 +156,11 @@ async function download(API, authHeaders, dest, files, keys, counter) {
|
|
|
134
156
|
return failed;
|
|
135
157
|
}
|
|
136
158
|
|
|
137
|
-
// The local files a restore would send: everything
|
|
138
|
-
//
|
|
159
|
+
// The local files a restore would send: everything in the file folder except
|
|
160
|
+
// names the engine reserves ("$...", its index and spool) and partial
|
|
139
161
|
// downloads, as { key: [size, mtime] }.
|
|
140
162
|
function localFiles(root) {
|
|
141
|
-
const base = join(root,
|
|
163
|
+
const base = join(root, filesDir(root));
|
|
142
164
|
const out = {};
|
|
143
165
|
const walk = (dir) => {
|
|
144
166
|
let ents;
|
|
@@ -177,11 +199,16 @@ async function postJson(API, authHeaders, path, body) {
|
|
|
177
199
|
return res.json();
|
|
178
200
|
}
|
|
179
201
|
|
|
180
|
-
// Send each key from
|
|
202
|
+
// Send each key from the file folder to the bucket. Returns the failures.
|
|
203
|
+
// Each file sent then takes the bucket's date for it as its mtime, the way a
|
|
204
|
+
// download does, so neither `backup --storage` nor the next push moves it
|
|
205
|
+
// again.
|
|
181
206
|
async function upload(API, authHeaders, root, local, keys, onBytes) {
|
|
182
207
|
const failed = [];
|
|
208
|
+
const dir = filesDir(root);
|
|
209
|
+
const sent = [];
|
|
183
210
|
await pool(keys, 4, async (key) => {
|
|
184
|
-
const file = join(root,
|
|
211
|
+
const file = join(root, dir, ...key.split('/'));
|
|
185
212
|
const size = local[key][0];
|
|
186
213
|
try {
|
|
187
214
|
const plan = await postJson(API, authHeaders, '/storage-upload', { key, size });
|
|
@@ -193,6 +220,7 @@ async function upload(API, authHeaders, root, local, keys, onBytes) {
|
|
|
193
220
|
if (r.status !== 200) throw new Error(`HTTP ${r.status}`);
|
|
194
221
|
});
|
|
195
222
|
onBytes(size);
|
|
223
|
+
sent.push(key);
|
|
196
224
|
return;
|
|
197
225
|
}
|
|
198
226
|
const fd = fs.openSync(file, 'r');
|
|
@@ -211,6 +239,7 @@ async function upload(API, authHeaders, root, local, keys, onBytes) {
|
|
|
211
239
|
onBytes(len);
|
|
212
240
|
});
|
|
213
241
|
await postJson(API, authHeaders, '/storage-upload-complete', { key, uploadId: plan.uploadId, etags });
|
|
242
|
+
sent.push(key);
|
|
214
243
|
} catch (e) {
|
|
215
244
|
await postJson(API, authHeaders, '/storage-upload-complete', { key, uploadId: plan.uploadId, abort: true }).catch(() => {});
|
|
216
245
|
throw e;
|
|
@@ -221,7 +250,16 @@ async function upload(API, authHeaders, root, local, keys, onBytes) {
|
|
|
221
250
|
failed.push(`${key}: ${e.message}`);
|
|
222
251
|
}
|
|
223
252
|
});
|
|
253
|
+
if (sent.length) {
|
|
254
|
+
try {
|
|
255
|
+
const after = await manifest(API, authHeaders);
|
|
256
|
+
for (const key of sent) {
|
|
257
|
+
const r = after && after.files[key];
|
|
258
|
+
if (r && r[1]) fs.utimesSync(join(root, dir, ...key.split('/')), r[1], r[1]);
|
|
259
|
+
}
|
|
260
|
+
} catch {} // only the next run's comparison suffers
|
|
261
|
+
}
|
|
224
262
|
return failed;
|
|
225
263
|
}
|
|
226
264
|
|
|
227
|
-
module.exports = { FILES_DIR, manifest, changedRemote, download, localFiles, changedLocal, upload };
|
|
265
|
+
module.exports = { FILES_DIR, filesDir, manifest, changedRemote, download, localFiles, changedLocal, upload, fetchTo, pool };
|