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/lib/storage.js CHANGED
@@ -1,9 +1,13 @@
1
- // Object storage for `nukebase backup --storage` and `nukebase push --include
2
- // backup`. A project with object storage keeps its files (what the app stores
3
- // with setFile) in a bucket instead of server/files. They move straight between
4
- // this machine and the bucket through links the server signs for one file and
5
- // one hour; none of the bytes pass through the project's server. Locally they
6
- // live where a project without storage keeps them: server/files/<path>.
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 under dest/server/files: by
94
- // size, or by an mtime more than a second apart (a download sets the local
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, FILES_DIR, ...key.split('/'));
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.abs(Math.floor(st.mtimeMs / 1000) - mtime) > 1;
114
+ return st.size !== size || mtime > Math.floor(st.mtimeMs / 1000) + 1;
103
115
  });
104
116
  }
105
117
 
106
- // Download `keys` into dest/server/files. Each file is written to a .part
107
- // beside it and renamed when complete, so an interrupted run leaves no torn
108
- // file. counter() is a pass-through that counts bytes for the progress line.
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, FILES_DIR, ...key.split('/'));
116
- if (!target.startsWith(join(dest, FILES_DIR) + sep)) return;
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 retry(key, async () => {
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 under root/server/files
138
- // except names the engine reserves ("$...", its index and spool) and partial
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, FILES_DIR);
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 root/server/files to the bucket. Returns the failures.
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, FILES_DIR, ...key.split('/'));
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nukebase",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "description": "NukeBase CLI: create, deploy and manage NukeBase projects",
5
5
  "bin": {
6
6
  "nukebase": "bin/nukebase.js"