nukebase 2.2.3 → 2.3.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/bin/nukebase.js +2 -0
- package/lib/guide.js +4 -2
- package/lib/project-commands.js +127 -25
- package/lib/storage.js +227 -0
- package/package.json +1 -1
package/bin/nukebase.js
CHANGED
|
@@ -68,6 +68,8 @@ In a project folder (runs as the account that owns the project)
|
|
|
68
68
|
pull download code
|
|
69
69
|
update refresh the NukeBase engine files
|
|
70
70
|
backup [--out <dir>] download the backup set from the server
|
|
71
|
+
backup --storage also the app's files in file storage (new or
|
|
72
|
+
changed ones, straight from the storage)
|
|
71
73
|
env push make the hosted environment (process.env) equal
|
|
72
74
|
to ./.env, then restart the project
|
|
73
75
|
env list [--show] variable names set on the server
|
package/lib/guide.js
CHANGED
|
@@ -66,9 +66,11 @@ What it can do (features)
|
|
|
66
66
|
Safety net Every deploy can be undone, and the data can be
|
|
67
67
|
backed up to their computer at any time.
|
|
68
68
|
Secrets API keys for other services, kept out of the code.
|
|
69
|
-
Sizes 128 MB, 256 MB, 512 MB or 1 GB
|
|
69
|
+
Sizes 128 MB, 256 MB, 512 MB or 1 GB of memory, holding 25,
|
|
70
|
+
50, 100 or 200 GB of disk. The default is fine to
|
|
70
71
|
start; it can be changed at any time from the
|
|
71
|
-
dashboard
|
|
72
|
+
dashboard, and a project moves up a size by itself
|
|
73
|
+
when its disk is 90% full.
|
|
72
74
|
|
|
73
75
|
Good fits: booking and scheduling, member or client portals, sign-up lists
|
|
74
76
|
and waitlists, small shops and catalogs, dashboards and tools for a business,
|
package/lib/project-commands.js
CHANGED
|
@@ -13,6 +13,7 @@ const { Transform, PassThrough } = require('stream');
|
|
|
13
13
|
const { pipeline } = require('stream/promises');
|
|
14
14
|
const tar = require('tar');
|
|
15
15
|
const createClient = require('./sdk');
|
|
16
|
+
const storage = require('./storage');
|
|
16
17
|
const creds = require('./credentials');
|
|
17
18
|
const loginFlows = require('./login');
|
|
18
19
|
const { findProject, readConfig, writeConfig, restoreSettings, migrateLegacy } = require('./project');
|
|
@@ -186,6 +187,8 @@ function progressMeter(totalBytes) {
|
|
|
186
187
|
counter() {
|
|
187
188
|
return new Transform({ transform(chunk, enc, cb) { bytes += chunk.length; draw(false); cb(null, chunk); } });
|
|
188
189
|
},
|
|
190
|
+
// Bytes moved without a stream to count them (storage uploads).
|
|
191
|
+
add(n) { bytes += n; draw(false); },
|
|
189
192
|
bytes: () => bytes,
|
|
190
193
|
done() {
|
|
191
194
|
draw(true);
|
|
@@ -1514,10 +1517,33 @@ async function push() {
|
|
|
1514
1517
|
process.exit(1);
|
|
1515
1518
|
}
|
|
1516
1519
|
|
|
1517
|
-
const
|
|
1520
|
+
const synced = await res.json();
|
|
1521
|
+
const { remove, diskWarning } = synced;
|
|
1518
1522
|
if (diskWarning) console.log('⚠️ ' + diskWarning);
|
|
1519
1523
|
|
|
1520
|
-
|
|
1524
|
+
// --include backup on a project with file storage: its files (server/files)
|
|
1525
|
+
// go straight to the storage, not to the server, and only the ones it lacks
|
|
1526
|
+
// or holds at another size. Nothing there is deleted.
|
|
1527
|
+
let store = null;
|
|
1528
|
+
let storageLocal = {};
|
|
1529
|
+
let storageUp = [];
|
|
1530
|
+
if (INCLUDE_BACKUP) {
|
|
1531
|
+
try {
|
|
1532
|
+
store = await storage.manifest(API, authHeaders);
|
|
1533
|
+
} catch (e) {
|
|
1534
|
+
console.error('❌ ' + e.message);
|
|
1535
|
+
process.exit(1);
|
|
1536
|
+
}
|
|
1537
|
+
if (store && store.storage) {
|
|
1538
|
+
storageLocal = storage.localFiles(root);
|
|
1539
|
+
storageUp = storage.changedLocal(storageLocal, store.files);
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
const inFilesDir = (f) => f === storage.FILES_DIR || f.startsWith(storage.FILES_DIR + '/');
|
|
1543
|
+
const upload = store && store.storage ? synced.upload.filter((f) => !inFilesDir(f)) : synced.upload;
|
|
1544
|
+
const storageBytes = storageUp.reduce((n, k) => n + storageLocal[k][0], 0);
|
|
1545
|
+
|
|
1546
|
+
if (!upload.length && !remove.length && !storageUp.length) {
|
|
1521
1547
|
console.log('✓ Already up to date');
|
|
1522
1548
|
if (AUTO_YES) process.exit(0);
|
|
1523
1549
|
watchLogs();
|
|
@@ -1566,10 +1592,16 @@ async function push() {
|
|
|
1566
1592
|
console.log(' your copy does not have.');
|
|
1567
1593
|
console.log('\n The project is stopped before extraction, so the running');
|
|
1568
1594
|
console.log(' process cannot flush its in-memory database over the upload.');
|
|
1569
|
-
} else {
|
|
1595
|
+
} else if (!storageUp.length) {
|
|
1570
1596
|
console.log('\n No backup-set files found locally — nothing extra to send.');
|
|
1571
1597
|
console.log(' Run `nukebase backup` first if you meant to restore a copy.');
|
|
1572
1598
|
}
|
|
1599
|
+
if (store && store.storage) {
|
|
1600
|
+
console.log(`\n File storage: ${storageUp.length} file(s), ${(storageBytes / 1024 / 1024).toFixed(2)} MB from ${storage.FILES_DIR}`);
|
|
1601
|
+
console.log(' go straight to the storage (missing or changed ones only; nothing there is deleted).');
|
|
1602
|
+
storageUp.slice(0, 10).forEach(f => console.log(' ⇈ ' + f));
|
|
1603
|
+
if (storageUp.length > 10) console.log(` ... and ${storageUp.length - 10} more`);
|
|
1604
|
+
}
|
|
1573
1605
|
console.log('');
|
|
1574
1606
|
}
|
|
1575
1607
|
|
|
@@ -1589,13 +1621,28 @@ async function push() {
|
|
|
1589
1621
|
}
|
|
1590
1622
|
}
|
|
1591
1623
|
|
|
1592
|
-
console.log(`\n📊 Total: ${upload.length} to upload, ${remove.length} to remove`
|
|
1624
|
+
console.log(`\n📊 Total: ${upload.length} to upload, ${remove.length} to remove` +
|
|
1625
|
+
(storageUp.length ? `, ${storageUp.length} to file storage` : ''));
|
|
1593
1626
|
const ok = await confirm(`Continue? (y/n) `);
|
|
1594
1627
|
if (!ok) {
|
|
1595
1628
|
console.log('Aborted.');
|
|
1596
1629
|
process.exit(0);
|
|
1597
1630
|
}
|
|
1598
1631
|
|
|
1632
|
+
// Storage first, while the app still runs: it cannot see the new files until
|
|
1633
|
+
// its index is rebuilt at the restart below, so nothing is half-visible.
|
|
1634
|
+
let storageFailed = [];
|
|
1635
|
+
if (storageUp.length) {
|
|
1636
|
+
console.log(`\n File storage (${storageUp.length} file(s)):`);
|
|
1637
|
+
const meter = progressMeter(storageBytes);
|
|
1638
|
+
storageFailed = await storage.upload(API, authHeaders, root, storageLocal, storageUp, (n) => meter.add(n));
|
|
1639
|
+
meter.done();
|
|
1640
|
+
if (storageFailed.length) {
|
|
1641
|
+
console.error(`\n❌ ${storageFailed.length} file(s) did not reach the storage; push again to send them:`);
|
|
1642
|
+
storageFailed.slice(0, 10).forEach(f => console.error(' ' + f));
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1599
1646
|
if (upload.length) {
|
|
1600
1647
|
const sizeOf = (f) => {
|
|
1601
1648
|
const entry = localFiles[f];
|
|
@@ -1672,6 +1719,13 @@ async function push() {
|
|
|
1672
1719
|
}
|
|
1673
1720
|
}
|
|
1674
1721
|
|
|
1722
|
+
// Files went to the storage: the app's file index is rebuilt from the
|
|
1723
|
+
// storage when it starts.
|
|
1724
|
+
if (storageUp.length > storageFailed.length) {
|
|
1725
|
+
const r = await fetch(`${API}/storage-reindex`, { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: '{}' });
|
|
1726
|
+
if (!r.ok) console.error('⚠️ Could not reset the file index:', r.status, await r.text());
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1675
1729
|
console.log('\n🔄 Restarting server...');
|
|
1676
1730
|
|
|
1677
1731
|
// Restart also seals the deploy snapshot server-side, which is what makes it
|
|
@@ -1693,15 +1747,17 @@ async function push() {
|
|
|
1693
1747
|
process.exit(1);
|
|
1694
1748
|
}
|
|
1695
1749
|
|
|
1696
|
-
console.log(`\n✅ Pushed! (${upload.length} uploaded, ${remove.length} removed
|
|
1750
|
+
console.log(`\n✅ Pushed! (${upload.length} uploaded, ${remove.length} removed` +
|
|
1751
|
+
(storageUp.length ? `, ${storageUp.length - storageFailed.length} to file storage` : '') + ')');
|
|
1697
1752
|
console.log(` Deploy ${deployId} — run 'nukebase rollback' to undo this push`);
|
|
1753
|
+
if (storageFailed.length) process.exitCode = 1;
|
|
1698
1754
|
|
|
1699
1755
|
// Non-interactive: tail logs only until the server settles, then exit so the
|
|
1700
1756
|
// AI agent / CI job returns on its own. Interactive keeps the live tail above.
|
|
1701
1757
|
if (AUTO_YES) {
|
|
1702
1758
|
await tailLogsUntilSettled();
|
|
1703
1759
|
console.log('\n✅ Deploy complete.');
|
|
1704
|
-
process.exit(0);
|
|
1760
|
+
process.exit(storageFailed.length ? 1 : 0);
|
|
1705
1761
|
}
|
|
1706
1762
|
}
|
|
1707
1763
|
|
|
@@ -2238,12 +2294,12 @@ async function backup() {
|
|
|
2238
2294
|
changed = (download || []).filter(rel => backupPaths.some(p => rel === p || rel.startsWith(p + '/')));
|
|
2239
2295
|
fetchBytes = 0;
|
|
2240
2296
|
if (!changed.length) {
|
|
2241
|
-
console.log('\n
|
|
2242
|
-
|
|
2297
|
+
console.log('\n The server has no files under those paths.');
|
|
2298
|
+
} else {
|
|
2299
|
+
console.log(`\n ${changed.length} file(s) to download`);
|
|
2300
|
+
changed.slice(0, 10).forEach(f => console.log(' ↓ ' + f));
|
|
2301
|
+
if (changed.length > 10) console.log(` ... and ${changed.length - 10} more`);
|
|
2243
2302
|
}
|
|
2244
|
-
console.log(`\n ${changed.length} file(s) to download`);
|
|
2245
|
-
changed.slice(0, 10).forEach(f => console.log(' ↓ ' + f));
|
|
2246
|
-
if (changed.length > 10) console.log(` ... and ${changed.length - 10} more`);
|
|
2247
2303
|
} else {
|
|
2248
2304
|
if (!listRes.ok) {
|
|
2249
2305
|
console.error('❌ Could not list backup files:', listRes.status, await listRes.text());
|
|
@@ -2253,10 +2309,6 @@ async function backup() {
|
|
|
2253
2309
|
const { files: serverFiles } = await listRes.json();
|
|
2254
2310
|
backupSizes = serverFiles || {};
|
|
2255
2311
|
const names = Object.keys(serverFiles);
|
|
2256
|
-
if (!names.length) {
|
|
2257
|
-
console.log('\n Nothing to back up — the server has no files under those paths.');
|
|
2258
|
-
process.exit(0);
|
|
2259
|
-
}
|
|
2260
2312
|
|
|
2261
2313
|
// Same size+mtime comparison sync uses. mtime survives the tar round-trip
|
|
2262
2314
|
// closely enough that a 1s tolerance avoids re-fetching unchanged files.
|
|
@@ -2271,14 +2323,50 @@ async function backup() {
|
|
|
2271
2323
|
const totalBytes = names.reduce((n, rel) => n + (serverFiles[rel][0] || 0), 0);
|
|
2272
2324
|
fetchBytes = changed.reduce((n, rel) => n + (serverFiles[rel][0] || 0), 0);
|
|
2273
2325
|
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2326
|
+
if (!names.length) {
|
|
2327
|
+
console.log('\n The server has no files under those paths.');
|
|
2328
|
+
} else {
|
|
2329
|
+
console.log(`\n ${names.length} file(s), ${(totalBytes / 1024 / 1024).toFixed(2)} MB on the server`);
|
|
2330
|
+
if (!changed.length) console.log(' ✓ Already up to date.');
|
|
2331
|
+
else {
|
|
2332
|
+
console.log(` ${changed.length} changed, ${(fetchBytes / 1024 / 1024).toFixed(2)} MB to download`);
|
|
2333
|
+
changed.slice(0, 10).forEach(f => console.log(' ↓ ' + f));
|
|
2334
|
+
if (changed.length > 10) console.log(` ... and ${changed.length - 10} more`);
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
// File storage: a project whose files live in object storage. Listed always;
|
|
2340
|
+
// downloaded with --storage, straight from the storage, new or changed only.
|
|
2341
|
+
let store = null;
|
|
2342
|
+
let storageChanged = [];
|
|
2343
|
+
let storageBytes = 0;
|
|
2344
|
+
try {
|
|
2345
|
+
store = await storage.manifest(API, authHeaders);
|
|
2346
|
+
} catch (e) {
|
|
2347
|
+
console.error('⚠️ ' + e.message);
|
|
2348
|
+
}
|
|
2349
|
+
if (store && store.storage) {
|
|
2350
|
+
const keys = Object.keys(store.files);
|
|
2351
|
+
const total = keys.reduce((n, k) => n + store.files[k][0], 0);
|
|
2352
|
+
console.log(`\n File storage: ${keys.length} file(s), ${(total / 1024 / 1024).toFixed(2)} MB`);
|
|
2353
|
+
if (args.has('storage')) {
|
|
2354
|
+
storageChanged = storage.changedRemote(dest, store.files);
|
|
2355
|
+
storageBytes = storageChanged.reduce((n, k) => n + store.files[k][0], 0);
|
|
2356
|
+
if (!storageChanged.length) console.log(' ✓ Already up to date.');
|
|
2357
|
+
else {
|
|
2358
|
+
console.log(` ${storageChanged.length} changed, ${(storageBytes / 1024 / 1024).toFixed(2)} MB to download into ${storage.FILES_DIR}`);
|
|
2359
|
+
storageChanged.slice(0, 10).forEach(f => console.log(' ↓ ' + f));
|
|
2360
|
+
if (storageChanged.length > 10) console.log(` ... and ${storageChanged.length - 10} more`);
|
|
2361
|
+
}
|
|
2362
|
+
} else if (keys.length) {
|
|
2363
|
+
console.log(' Not downloaded. Add --storage to copy them too (only new or changed files).');
|
|
2278
2364
|
}
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
if (!changed.length && !storageChanged.length) {
|
|
2368
|
+
console.log('\n Nothing to download.\n');
|
|
2369
|
+
process.exit(0);
|
|
2282
2370
|
}
|
|
2283
2371
|
|
|
2284
2372
|
const ok = await confirm('\nContinue? (y/n) ');
|
|
@@ -2287,11 +2375,25 @@ async function backup() {
|
|
|
2287
2375
|
mkdirSync(dest, { recursive: true });
|
|
2288
2376
|
|
|
2289
2377
|
const started = Date.now();
|
|
2290
|
-
|
|
2291
|
-
|
|
2378
|
+
if (changed.length) {
|
|
2379
|
+
await downloadStreams(dest, changed,
|
|
2380
|
+
(rel) => (backupSizes[rel] && backupSizes[rel][0]) || 0, fetchBytes || 0);
|
|
2381
|
+
}
|
|
2382
|
+
let storageFailed = [];
|
|
2383
|
+
if (storageChanged.length) {
|
|
2384
|
+
console.log(`\n File storage (${storageChanged.length} file(s)):`);
|
|
2385
|
+
const meter = progressMeter(storageBytes);
|
|
2386
|
+
storageFailed = await storage.download(API, authHeaders, dest, store.files, storageChanged, () => meter.counter());
|
|
2387
|
+
meter.done();
|
|
2388
|
+
}
|
|
2292
2389
|
|
|
2293
2390
|
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
|
2294
|
-
|
|
2391
|
+
if (storageFailed.length) {
|
|
2392
|
+
console.error(`\n❌ ${storageFailed.length} storage file(s) failed; run the backup again to fetch them:`);
|
|
2393
|
+
storageFailed.slice(0, 10).forEach(f => console.error(' ' + f));
|
|
2394
|
+
process.exit(1);
|
|
2395
|
+
}
|
|
2396
|
+
console.log(`\n\n✅ Backed up ${changed.length + storageChanged.length} file(s) in ${secs}s`);
|
|
2295
2397
|
console.log(` ${dest}`);
|
|
2296
2398
|
if (dest === root) {
|
|
2297
2399
|
console.log('\n These paths are in `exclude`, so push and pull will ignore them.');
|
package/lib/storage.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
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>.
|
|
7
|
+
//
|
|
8
|
+
// Every transfer is IPv4: the storage provider stalls parallel uploads over
|
|
9
|
+
// IPv6 (measured from the server: 13 of 18 stalled, against none over IPv4).
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const https = require('https');
|
|
12
|
+
const http = require('http');
|
|
13
|
+
const { join, dirname, relative, sep } = require('path');
|
|
14
|
+
const { pipeline } = require('stream/promises');
|
|
15
|
+
|
|
16
|
+
const FILES_DIR = 'server/files';
|
|
17
|
+
const IDLE_MS = 30000;
|
|
18
|
+
const TRIES = 4;
|
|
19
|
+
const PARALLEL = 6;
|
|
20
|
+
|
|
21
|
+
// { storage: false } from a server without storage for this project, null from
|
|
22
|
+
// a server that predates it; otherwise { storage: true, files: { key: [size, mtime] } }.
|
|
23
|
+
async function manifest(API, authHeaders) {
|
|
24
|
+
const files = {};
|
|
25
|
+
let after = null;
|
|
26
|
+
for (;;) {
|
|
27
|
+
const res = await fetch(`${API}/storage-manifest`, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
30
|
+
body: JSON.stringify(after ? { after } : {})
|
|
31
|
+
});
|
|
32
|
+
if (res.status === 404) return null;
|
|
33
|
+
if (!res.ok) throw new Error(`Could not list the storage files: ${res.status} ${await res.text()}`);
|
|
34
|
+
const page = await res.json();
|
|
35
|
+
if (!page.storage) return { storage: false, files: {} };
|
|
36
|
+
Object.assign(files, page.files || {});
|
|
37
|
+
if (!page.next) return { storage: true, files };
|
|
38
|
+
after = page.next;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// One request to the bucket, IPv4, failing on IDLE_MS without progress.
|
|
43
|
+
// Resolves { status, headers, stream } once headers arrive.
|
|
44
|
+
function request(method, url, body) {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const u = new URL(url);
|
|
47
|
+
const mod = u.protocol === 'http:' ? http : https;
|
|
48
|
+
const req = mod.request({
|
|
49
|
+
method, hostname: u.hostname, port: u.port || undefined, path: u.pathname + u.search, family: 4,
|
|
50
|
+
headers: body ? { 'content-length': body.length } : {}
|
|
51
|
+
}, (resp) => {
|
|
52
|
+
resp.setTimeout(IDLE_MS, () => resp.destroy(new Error('the storage stopped sending')));
|
|
53
|
+
resolve({ status: resp.statusCode, headers: resp.headers, stream: resp });
|
|
54
|
+
});
|
|
55
|
+
req.setTimeout(IDLE_MS, () => req.destroy(new Error('the storage stopped answering')));
|
|
56
|
+
req.on('error', reject);
|
|
57
|
+
req.end(body || undefined);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function retry(what, fn) {
|
|
62
|
+
let last;
|
|
63
|
+
for (let i = 0; i < TRIES; i++) {
|
|
64
|
+
try { return await fn(); } catch (e) { last = e; }
|
|
65
|
+
await new Promise((r) => setTimeout(r, 500 * (i + 1)));
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`${what}: ${last.message}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Runs fn over items, `n` at a time.
|
|
71
|
+
async function pool(items, n, fn) {
|
|
72
|
+
let i = 0;
|
|
73
|
+
await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
|
|
74
|
+
while (i < items.length) await fn(items[i++]);
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Signed links for many keys, 500 per request.
|
|
79
|
+
async function links(API, authHeaders, keys) {
|
|
80
|
+
const out = {};
|
|
81
|
+
for (let i = 0; i < keys.length; i += 500) {
|
|
82
|
+
const res = await fetch(`${API}/storage-links`, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
85
|
+
body: JSON.stringify({ keys: keys.slice(i, i + 500) })
|
|
86
|
+
});
|
|
87
|
+
if (!res.ok) throw new Error(`Could not get download links: ${res.status} ${await res.text()}`);
|
|
88
|
+
Object.assign(out, (await res.json()).links);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
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).
|
|
96
|
+
function changedRemote(dest, files) {
|
|
97
|
+
return Object.keys(files).filter((key) => {
|
|
98
|
+
const local = join(dest, FILES_DIR, ...key.split('/'));
|
|
99
|
+
let st;
|
|
100
|
+
try { st = fs.statSync(local); } catch { return true; }
|
|
101
|
+
const [size, mtime] = files[key];
|
|
102
|
+
return st.size !== size || Math.abs(Math.floor(st.mtimeMs / 1000) - mtime) > 1;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
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.
|
|
109
|
+
async function download(API, authHeaders, dest, files, keys, counter) {
|
|
110
|
+
const failed = [];
|
|
111
|
+
for (let i = 0; i < keys.length; i += 500) {
|
|
112
|
+
const batch = keys.slice(i, i + 500);
|
|
113
|
+
const urls = await links(API, authHeaders, batch); // an hour each: fetched per batch
|
|
114
|
+
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';
|
|
119
|
+
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);
|
|
126
|
+
const mtime = files[key][1];
|
|
127
|
+
if (mtime) fs.utimesSync(target, mtime, mtime);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
try { fs.unlinkSync(part); } catch {}
|
|
130
|
+
failed.push(e.message);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return failed;
|
|
135
|
+
}
|
|
136
|
+
|
|
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
|
|
139
|
+
// downloads, as { key: [size, mtime] }.
|
|
140
|
+
function localFiles(root) {
|
|
141
|
+
const base = join(root, FILES_DIR);
|
|
142
|
+
const out = {};
|
|
143
|
+
const walk = (dir) => {
|
|
144
|
+
let ents;
|
|
145
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
146
|
+
for (const e of ents) {
|
|
147
|
+
if (e.name.startsWith('$') || e.name.endsWith('.part') || e.name.endsWith('.tmp')) continue;
|
|
148
|
+
const full = join(dir, e.name);
|
|
149
|
+
if (e.isDirectory()) walk(full);
|
|
150
|
+
else if (e.isFile()) {
|
|
151
|
+
const st = fs.statSync(full);
|
|
152
|
+
out[relative(base, full).split(sep).join('/')] = [st.size, Math.floor(st.mtimeMs / 1000)];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
walk(base);
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// What a restore uploads: local files the bucket lacks or holds at another
|
|
161
|
+
// size, or holds older than the local copy. Nothing is ever deleted.
|
|
162
|
+
function changedLocal(local, remote) {
|
|
163
|
+
return Object.keys(local).filter((key) => {
|
|
164
|
+
const r = remote[key];
|
|
165
|
+
if (!r) return true;
|
|
166
|
+
return r[0] !== local[key][0] || local[key][1] > r[1] + 1;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function postJson(API, authHeaders, path, body) {
|
|
171
|
+
const res = await fetch(`${API}${path}`, {
|
|
172
|
+
method: 'POST',
|
|
173
|
+
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
174
|
+
body: JSON.stringify(body)
|
|
175
|
+
});
|
|
176
|
+
if (!res.ok) throw new Error(`${path}: ${res.status} ${await res.text()}`);
|
|
177
|
+
return res.json();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Send each key from root/server/files to the bucket. Returns the failures.
|
|
181
|
+
async function upload(API, authHeaders, root, local, keys, onBytes) {
|
|
182
|
+
const failed = [];
|
|
183
|
+
await pool(keys, 4, async (key) => {
|
|
184
|
+
const file = join(root, FILES_DIR, ...key.split('/'));
|
|
185
|
+
const size = local[key][0];
|
|
186
|
+
try {
|
|
187
|
+
const plan = await postJson(API, authHeaders, '/storage-upload', { key, size });
|
|
188
|
+
if (plan.url) {
|
|
189
|
+
const body = fs.readFileSync(file);
|
|
190
|
+
await retry(key, async () => {
|
|
191
|
+
const r = await request('PUT', plan.url, body);
|
|
192
|
+
r.stream.resume();
|
|
193
|
+
if (r.status !== 200) throw new Error(`HTTP ${r.status}`);
|
|
194
|
+
});
|
|
195
|
+
onBytes(size);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const fd = fs.openSync(file, 'r');
|
|
199
|
+
try {
|
|
200
|
+
const etags = new Array(plan.parts.length);
|
|
201
|
+
await pool(plan.parts.map((url, i) => ({ url, i })), 4, async ({ url, i }) => {
|
|
202
|
+
const len = Math.min(plan.partSize, size - i * plan.partSize);
|
|
203
|
+
const buf = Buffer.alloc(len);
|
|
204
|
+
fs.readSync(fd, buf, 0, len, i * plan.partSize);
|
|
205
|
+
await retry(`${key} part ${i + 1}`, async () => {
|
|
206
|
+
const r = await request('PUT', url, buf);
|
|
207
|
+
r.stream.resume();
|
|
208
|
+
if (r.status !== 200 || !r.headers.etag) throw new Error(`HTTP ${r.status}`);
|
|
209
|
+
etags[i] = r.headers.etag;
|
|
210
|
+
});
|
|
211
|
+
onBytes(len);
|
|
212
|
+
});
|
|
213
|
+
await postJson(API, authHeaders, '/storage-upload-complete', { key, uploadId: plan.uploadId, etags });
|
|
214
|
+
} catch (e) {
|
|
215
|
+
await postJson(API, authHeaders, '/storage-upload-complete', { key, uploadId: plan.uploadId, abort: true }).catch(() => {});
|
|
216
|
+
throw e;
|
|
217
|
+
} finally {
|
|
218
|
+
fs.closeSync(fd);
|
|
219
|
+
}
|
|
220
|
+
} catch (e) {
|
|
221
|
+
failed.push(`${key}: ${e.message}`);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
return failed;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = { FILES_DIR, manifest, changedRemote, download, localFiles, changedLocal, upload };
|