@coderook/cli 0.22.1 → 0.22.2
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/dist/cli/src/cli.js +50 -41
- package/dist/cli/src/offline.js +28 -0
- package/dist/desktop-app/src/main/compress.js +6 -31
- package/dist/desktop-app/src/main/download.js +200 -4
- package/dist/desktop-app/src/main/upload.js +296 -33
- package/dist/desktop-app/src/shared/chunking.js +21 -1
- package/dist/desktop-app/src/shared/compression_policy.js +35 -0
- package/dist/desktop-app/src/shared/telemetry.js +47 -0
- package/package.json +1 -1
package/dist/cli/src/cli.js
CHANGED
|
@@ -265,7 +265,18 @@ async function commandProjects() {
|
|
|
265
265
|
}
|
|
266
266
|
async function commandStatus(parsed) {
|
|
267
267
|
const folder = folderFor(parsed);
|
|
268
|
-
|
|
268
|
+
let link;
|
|
269
|
+
let baseline;
|
|
270
|
+
try {
|
|
271
|
+
({ link, baseline } = await reconcile(folder));
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
link = await (0, config_js_1.readLink)(folder);
|
|
275
|
+
if (!link?.manifest)
|
|
276
|
+
throw error;
|
|
277
|
+
baseline = new Map(Object.entries(link.local ?? link.manifest));
|
|
278
|
+
console.log(dim("Offline — comparing with the last Version cached for this folder."));
|
|
279
|
+
}
|
|
269
280
|
const rules = await (0, worktree_js_1.readRules)(folder);
|
|
270
281
|
const files = await (0, worktree_js_1.changedFiles)(folder, rules, baseline);
|
|
271
282
|
console.log(bold(node_path_1.default.basename(folder)) + dim(` ${folder}`));
|
|
@@ -463,12 +474,6 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
|
|
|
463
474
|
}
|
|
464
475
|
console.log(dim("Sending them anyway, because --allow-secrets was given."));
|
|
465
476
|
}
|
|
466
|
-
if (hasFlag(parsed, "dry-run", "n")) {
|
|
467
|
-
console.log(`${files.length} file${files.length === 1 ? "" : "s"} would be sent:`);
|
|
468
|
-
for (const file of files)
|
|
469
|
-
console.log(` ${file.path}`);
|
|
470
|
-
return 0;
|
|
471
|
-
}
|
|
472
477
|
if (added) {
|
|
473
478
|
console.log(`${accent("Added an MIT licence")}, so other people may use this.`);
|
|
474
479
|
console.log(dim(` Change it with `) +
|
|
@@ -480,42 +485,46 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
|
|
|
480
485
|
}
|
|
481
486
|
const line = progressLine();
|
|
482
487
|
const uploader = new upload_js_1.Uploader(config_js_1.credentials);
|
|
488
|
+
const uploadRequest = {
|
|
489
|
+
localPath: folder,
|
|
490
|
+
include: files.map((file) => file.path),
|
|
491
|
+
deletions: files.filter((file) => file.deleted).map((file) => file.path),
|
|
492
|
+
message,
|
|
493
|
+
projectName: link?.slug ?? node_path_1.default.basename(folder),
|
|
494
|
+
repositoryId: link?.repositoryId ?? null,
|
|
495
|
+
// Every current CLI publish states its ancestry. A brand-new project is
|
|
496
|
+
// explicitly based on an empty Track; a linked folder names the immutable
|
|
497
|
+
// Version it was last reconciled with.
|
|
498
|
+
baseVersionId: link?.baseVersionId ?? null,
|
|
499
|
+
track: flagText(parsed, "track") ?? (await (0, track_commands_js_1.trackFor)(folder)),
|
|
500
|
+
...(link?.baseVersionId
|
|
501
|
+
? { expectedHeadVersionId: link.baseVersionId }
|
|
502
|
+
: {}),
|
|
503
|
+
...(link ? { known: link.local ?? link.manifest ?? {} } : {}),
|
|
504
|
+
};
|
|
483
505
|
let result;
|
|
484
506
|
try {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
known version. A folder linked before versions were recorded says
|
|
507
|
-
nothing rather than guessing, and publishes as it always did.
|
|
508
|
-
*/
|
|
509
|
-
...(link?.baseVersionId
|
|
510
|
-
? { expectedHeadVersionId: link.baseVersionId }
|
|
511
|
-
: {}),
|
|
512
|
-
/*
|
|
513
|
-
What this folder believed the project held. It is how the service
|
|
514
|
-
tells a file this person deleted from one they never had, and
|
|
515
|
-
without it somebody else's work disappears at the next save.
|
|
516
|
-
*/
|
|
517
|
-
...(link ? { known: link.local ?? link.manifest ?? {} } : {}),
|
|
518
|
-
}, (progress) => {
|
|
507
|
+
const plan = await uploader.plan(uploadRequest, (progress) => {
|
|
508
|
+
line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
|
|
509
|
+
`${"plan".padEnd(7)} ${progress.files}/${progress.totalFiles} ` +
|
|
510
|
+
dim(progress.path.slice(-40)));
|
|
511
|
+
});
|
|
512
|
+
done(line);
|
|
513
|
+
console.log(`${accent("Upload plan")} ${bytes(plan.sourceBytes)} selected → ` +
|
|
514
|
+
`${bytes(plan.compactedBytes)} compacted; ${bytes(plan.chargeableBytes)} new storage.`);
|
|
515
|
+
console.log(plan.allowance.exempt
|
|
516
|
+
? dim("Annual plan: the monthly staged upload allowance does not apply.")
|
|
517
|
+
: dim(`Monthly stage ${plan.allowance.stage}: ${plan.allowance.unlockedPercent}% unlocked, ` +
|
|
518
|
+
`${bytes(plan.allowance.remainingBytes)} remains after this reservation.`));
|
|
519
|
+
if (hasFlag(parsed, "dry-run", "n")) {
|
|
520
|
+
await uploader.cancelPlan(plan, true);
|
|
521
|
+
console.log(`${files.length} file${files.length === 1 ? "" : "s"} would be sent or reused:`);
|
|
522
|
+
for (const file of files)
|
|
523
|
+
console.log(` ${file.path}`);
|
|
524
|
+
console.log("Nothing was uploaded and no Version was created.");
|
|
525
|
+
return 0;
|
|
526
|
+
}
|
|
527
|
+
result = await uploader.execute(uploadRequest, plan, (progress) => {
|
|
519
528
|
line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
|
|
520
529
|
`${progress.stage.padEnd(7)} ${progress.files}/${progress.totalFiles} ` +
|
|
521
530
|
dim(progress.path.slice(-40)));
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.queueOfflineCandidate = queueOfflineCandidate;
|
|
7
|
+
exports.readOfflineCandidate = readOfflineCandidate;
|
|
8
|
+
const promises_1 = require("node:fs/promises");
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
async function queueOfflineCandidate(folder, candidate) {
|
|
11
|
+
const directory = node_path_1.default.join(folder, ".coderook", "outbox");
|
|
12
|
+
await (0, promises_1.mkdir)(directory, { recursive: true });
|
|
13
|
+
const body = {
|
|
14
|
+
format: "coderook-offline-candidate-v1",
|
|
15
|
+
createdAt: new Date().toISOString(),
|
|
16
|
+
...candidate,
|
|
17
|
+
};
|
|
18
|
+
const name = `${Date.now()}-${crypto.randomUUID()}.json`;
|
|
19
|
+
const destination = node_path_1.default.join(directory, name);
|
|
20
|
+
await (0, promises_1.writeFile)(destination, JSON.stringify(body, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
21
|
+
return destination;
|
|
22
|
+
}
|
|
23
|
+
async function readOfflineCandidate(file) {
|
|
24
|
+
const value = JSON.parse(await (0, promises_1.readFile)(file, "utf8"));
|
|
25
|
+
if (value.format !== "coderook-offline-candidate-v1")
|
|
26
|
+
throw new Error("Unsupported offline candidate");
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.looksCompressed =
|
|
3
|
+
exports.looksCompressed = void 0;
|
|
4
4
|
exports.encodeForUpload = encodeForUpload;
|
|
5
5
|
/**
|
|
6
6
|
* Compressing an object before it is sent.
|
|
@@ -29,6 +29,9 @@ exports.encodeForUpload = encodeForUpload;
|
|
|
29
29
|
const node_crypto_1 = require("node:crypto");
|
|
30
30
|
const node_zlib_1 = require("node:zlib");
|
|
31
31
|
const node_util_1 = require("node:util");
|
|
32
|
+
const compression_policy_1 = require("../shared/compression_policy");
|
|
33
|
+
var compression_policy_2 = require("../shared/compression_policy");
|
|
34
|
+
Object.defineProperty(exports, "looksCompressed", { enumerable: true, get: function () { return compression_policy_2.looksCompressed; } });
|
|
32
35
|
const deflate = (0, node_util_1.promisify)(node_zlib_1.gzip);
|
|
33
36
|
/*
|
|
34
37
|
Extensions whose contents are already compressed. Matching on the name is a
|
|
@@ -36,30 +39,6 @@ const deflate = (0, node_util_1.promisify)(node_zlib_1.gzip);
|
|
|
36
39
|
is measured rather than guessed, so a wrong entry here costs a little time
|
|
37
40
|
and never a wrong result.
|
|
38
41
|
*/
|
|
39
|
-
const ALREADY_COMPRESSED = new Set([
|
|
40
|
-
".7z", ".aac", ".avi", ".br", ".bz2", ".cbx", ".docx", ".flac", ".gif",
|
|
41
|
-
".gz", ".heic", ".jpeg", ".jpg", ".jxl", ".m4a", ".m4v", ".mkv", ".mov",
|
|
42
|
-
".mp3", ".mp4", ".odp", ".ods", ".odt", ".ogg", ".opus", ".pdf", ".png",
|
|
43
|
-
".pptx", ".rar", ".safetensors", ".webm", ".webp", ".xlsx", ".xz", ".zip",
|
|
44
|
-
".zst",
|
|
45
|
-
]);
|
|
46
|
-
/**
|
|
47
|
-
* Below this, compression is not worth the round trip through zlib — the
|
|
48
|
-
* saving is measured in bytes and every file pays the CPU.
|
|
49
|
-
*/
|
|
50
|
-
const SMALLEST_WORTH_TRYING = 4 * 1024;
|
|
51
|
-
/**
|
|
52
|
-
* Keep the compressed form only if it saved something worth having. A file
|
|
53
|
-
* that shrinks by two percent is not worth the service decompressing it on
|
|
54
|
-
* every read for the rest of its life.
|
|
55
|
-
*/
|
|
56
|
-
const WORTHWHILE = 0.95;
|
|
57
|
-
function looksCompressed(relativePath) {
|
|
58
|
-
const dot = relativePath.lastIndexOf(".");
|
|
59
|
-
if (dot < 0)
|
|
60
|
-
return false;
|
|
61
|
-
return ALREADY_COMPRESSED.has(relativePath.slice(dot).toLowerCase());
|
|
62
|
-
}
|
|
63
42
|
/**
|
|
64
43
|
* Decide how one object should travel.
|
|
65
44
|
*
|
|
@@ -73,15 +52,11 @@ async function encodeForUpload(original, relativePath, allowGzip) {
|
|
|
73
52
|
encoding: "identity",
|
|
74
53
|
storedSha256: "",
|
|
75
54
|
};
|
|
76
|
-
if (!allowGzip)
|
|
77
|
-
return plain;
|
|
78
|
-
if (original.byteLength < SMALLEST_WORTH_TRYING)
|
|
79
|
-
return plain;
|
|
80
|
-
if (looksCompressed(relativePath))
|
|
55
|
+
if (!(0, compression_policy_1.shouldTryGzip)(relativePath, original.byteLength, allowGzip))
|
|
81
56
|
return plain;
|
|
82
57
|
try {
|
|
83
58
|
const packed = await deflate(original, { level: 6 });
|
|
84
|
-
if (
|
|
59
|
+
if (!(0, compression_policy_1.gzipIsWorthKeeping)(original.byteLength, packed.byteLength))
|
|
85
60
|
return plain;
|
|
86
61
|
return {
|
|
87
62
|
body: new Uint8Array(packed),
|
|
@@ -174,8 +174,39 @@ class Downloader {
|
|
|
174
174
|
}
|
|
175
175
|
return response;
|
|
176
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* Read a complete JSON response under the same retry rule as the request.
|
|
179
|
+
*
|
|
180
|
+
* `fetch()` resolves as soon as the headers arrive. A connection can still
|
|
181
|
+
* disappear while `.json()` is consuming the body; treating the response
|
|
182
|
+
* object as success meant a large file list failed outside the retry loop.
|
|
183
|
+
* These routes are reads, so repeating the whole request is safe and is the
|
|
184
|
+
* only way to replace a truncated body.
|
|
185
|
+
*/
|
|
186
|
+
async json(route) {
|
|
187
|
+
let wait = retry_js_1.RETRY_FIRST_WAIT_MS;
|
|
188
|
+
for (let attempt = 1;; attempt += 1) {
|
|
189
|
+
this.check();
|
|
190
|
+
try {
|
|
191
|
+
return await (await this.attempt(route)).json();
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
if (error instanceof DownloadCancelled)
|
|
195
|
+
throw error;
|
|
196
|
+
if (attempt >= retry_js_1.RETRY_ATTEMPTS || !(0, retry_js_1.worthRetrying)(error))
|
|
197
|
+
throw error;
|
|
198
|
+
try {
|
|
199
|
+
await (0, retry_js_1.pauseFor)(this.controller.signal, wait);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
throw new DownloadCancelled();
|
|
203
|
+
}
|
|
204
|
+
wait *= 2;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
177
208
|
async versions(repositoryId) {
|
|
178
|
-
const body =
|
|
209
|
+
const body = await this.json(`/v1/repositories/${repositoryId}/versions`);
|
|
179
210
|
return (body.versions ?? []).map((row) => ({
|
|
180
211
|
id: String(row.id ?? ""),
|
|
181
212
|
sequence: Number(row.sequence ?? 0),
|
|
@@ -187,13 +218,26 @@ class Downloader {
|
|
|
187
218
|
}));
|
|
188
219
|
}
|
|
189
220
|
async files(repositoryId, versionId) {
|
|
190
|
-
const body =
|
|
221
|
+
const body = await this.json(`/v1/repositories/${repositoryId}/versions/${versionId}/files`);
|
|
191
222
|
return (body.files ?? []).map((row) => {
|
|
192
223
|
const pieces = Array.isArray(row.chunks) ? row.chunks : null;
|
|
224
|
+
const packed = row.pack && typeof row.pack === "object"
|
|
225
|
+
? row.pack
|
|
226
|
+
: null;
|
|
193
227
|
return {
|
|
194
228
|
path: String(row.path ?? ""),
|
|
229
|
+
...(row.objectId ? { objectId: String(row.objectId) } : {}),
|
|
195
230
|
sha256: String(row.sha256 ?? ""),
|
|
196
231
|
sourceSize: Number(row.sourceSize ?? 0),
|
|
232
|
+
...(packed?.objectId
|
|
233
|
+
? {
|
|
234
|
+
pack: {
|
|
235
|
+
objectId: String(packed.objectId),
|
|
236
|
+
offset: Number(packed.offset ?? 0),
|
|
237
|
+
length: Number(packed.length ?? 0),
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
: {}),
|
|
197
241
|
...(pieces && pieces.length
|
|
198
242
|
? {
|
|
199
243
|
chunks: pieces.map((piece) => {
|
|
@@ -240,6 +284,92 @@ class Downloader {
|
|
|
240
284
|
await (0, promises_1.mkdir)(node_path_1.default.dirname(target), { recursive: true });
|
|
241
285
|
return this.fetchInto(repositoryId, versionId, file, target);
|
|
242
286
|
}
|
|
287
|
+
/**
|
|
288
|
+
* Reconstruct and verify every file in a Version without materialising the
|
|
289
|
+
* whole project at once. Solid packs are fetched once and large files use a
|
|
290
|
+
* single reusable scratch path, so the disk requirement is bounded by the
|
|
291
|
+
* largest individual file rather than the complete snapshot.
|
|
292
|
+
*/
|
|
293
|
+
async verify(repositoryId, versionId, scratch, report) {
|
|
294
|
+
const files = await this.files(repositoryId, versionId);
|
|
295
|
+
if (!files.length)
|
|
296
|
+
throw new Error("That version has no files");
|
|
297
|
+
const totalBytes = files.reduce((total, file) => total + file.sourceSize, 0);
|
|
298
|
+
const packed = new Map();
|
|
299
|
+
for (const file of files) {
|
|
300
|
+
const id = file.pack?.objectId;
|
|
301
|
+
if (!id)
|
|
302
|
+
continue;
|
|
303
|
+
const group = packed.get(id) ?? [];
|
|
304
|
+
group.push(file);
|
|
305
|
+
packed.set(id, group);
|
|
306
|
+
}
|
|
307
|
+
await (0, promises_1.rm)(scratch, { recursive: true, force: true });
|
|
308
|
+
await (0, promises_1.mkdir)(scratch, { recursive: true });
|
|
309
|
+
const temporary = node_path_1.default.join(scratch, "current-file.partial");
|
|
310
|
+
const restoredPacks = new Set();
|
|
311
|
+
const manifest = {};
|
|
312
|
+
let written = 0;
|
|
313
|
+
let bytes = 0;
|
|
314
|
+
const progress = (filePath) => report({
|
|
315
|
+
files: written,
|
|
316
|
+
totalFiles: files.length,
|
|
317
|
+
bytes,
|
|
318
|
+
totalBytes,
|
|
319
|
+
path: filePath,
|
|
320
|
+
percent: underway(bytes, totalBytes),
|
|
321
|
+
});
|
|
322
|
+
try {
|
|
323
|
+
for (const file of files) {
|
|
324
|
+
this.check();
|
|
325
|
+
progress(file.path);
|
|
326
|
+
const packId = file.pack?.objectId;
|
|
327
|
+
if (packId && restoredPacks.has(packId))
|
|
328
|
+
continue;
|
|
329
|
+
if (packId) {
|
|
330
|
+
const reply = await this.request(`/v1/repositories/${repositoryId}/objects/${packId}`);
|
|
331
|
+
const body = Buffer.from(await reply.arrayBuffer());
|
|
332
|
+
const packDigest = (0, node_crypto_1.createHash)("sha256").update(body).digest("hex");
|
|
333
|
+
const expectedPackDigest = reply.headers.get("x-coderook-sha256");
|
|
334
|
+
if (expectedPackDigest && packDigest !== expectedPackDigest) {
|
|
335
|
+
throw new Error("A solid pack did not arrive intact");
|
|
336
|
+
}
|
|
337
|
+
for (const member of packed.get(packId) ?? []) {
|
|
338
|
+
const offset = member.pack.offset;
|
|
339
|
+
const length = member.pack.length;
|
|
340
|
+
if (offset < 0 || length < 0 || offset + length > body.byteLength) {
|
|
341
|
+
throw new Error(`${member.path} points outside its solid pack`);
|
|
342
|
+
}
|
|
343
|
+
const digest = (0, node_crypto_1.createHash)("sha256")
|
|
344
|
+
.update(body.subarray(offset, offset + length))
|
|
345
|
+
.digest("hex");
|
|
346
|
+
if (member.sha256 && digest !== member.sha256) {
|
|
347
|
+
throw new Error(`${member.path} did not arrive intact`);
|
|
348
|
+
}
|
|
349
|
+
manifest[member.path] = digest;
|
|
350
|
+
written += 1;
|
|
351
|
+
bytes += member.sourceSize;
|
|
352
|
+
progress(member.path);
|
|
353
|
+
}
|
|
354
|
+
restoredPacks.add(packId);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
const digest = await this.fetchInto(repositoryId, versionId, file, temporary);
|
|
358
|
+
if (file.sha256 && digest !== file.sha256) {
|
|
359
|
+
throw new Error(`${file.path} did not arrive intact`);
|
|
360
|
+
}
|
|
361
|
+
manifest[file.path] = digest;
|
|
362
|
+
written += 1;
|
|
363
|
+
bytes += file.sourceSize;
|
|
364
|
+
await (0, promises_1.rm)(temporary, { force: true });
|
|
365
|
+
}
|
|
366
|
+
progress("Done");
|
|
367
|
+
return { files: written, bytes, manifest };
|
|
368
|
+
}
|
|
369
|
+
finally {
|
|
370
|
+
await (0, promises_1.rm)(scratch, { recursive: true, force: true });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
243
373
|
async fetchInto(repositoryId, versionId, file, target) {
|
|
244
374
|
const whole = (0, node_crypto_1.createHash)("sha256");
|
|
245
375
|
const pieces = file.chunks ?? [];
|
|
@@ -269,8 +399,10 @@ class Downloader {
|
|
|
269
399
|
}
|
|
270
400
|
})()
|
|
271
401
|
: (async function* () {
|
|
272
|
-
const reply = await request(
|
|
273
|
-
|
|
402
|
+
const reply = await request(file.objectId
|
|
403
|
+
? `/v1/repositories/${repositoryId}/objects/${file.objectId}`
|
|
404
|
+
: `/v1/repositories/${repositoryId}/versions/${versionId}/file` +
|
|
405
|
+
`?path=${encodeURIComponent(file.path)}`);
|
|
274
406
|
if (!reply.body)
|
|
275
407
|
return;
|
|
276
408
|
for await (const block of node_stream_1.Readable.fromWeb(reply.body)) {
|
|
@@ -322,6 +454,16 @@ class Downloader {
|
|
|
322
454
|
try {
|
|
323
455
|
let written = 0;
|
|
324
456
|
let bytes = 0;
|
|
457
|
+
const packed = new Map();
|
|
458
|
+
for (const file of files) {
|
|
459
|
+
const id = file.pack?.objectId;
|
|
460
|
+
if (!id)
|
|
461
|
+
continue;
|
|
462
|
+
const group = packed.get(id) ?? [];
|
|
463
|
+
group.push(file);
|
|
464
|
+
packed.set(id, group);
|
|
465
|
+
}
|
|
466
|
+
const restoredPacks = new Set();
|
|
325
467
|
for (const file of files) {
|
|
326
468
|
this.check();
|
|
327
469
|
report({
|
|
@@ -337,6 +479,60 @@ class Downloader {
|
|
|
337
479
|
if (parts.some((part) => part === ".." || part.includes("\0"))) {
|
|
338
480
|
throw new Error(`That version contains an unsafe path: ${file.path}`);
|
|
339
481
|
}
|
|
482
|
+
const packId = file.pack?.objectId;
|
|
483
|
+
if (packId && restoredPacks.has(packId))
|
|
484
|
+
continue;
|
|
485
|
+
if (packId) {
|
|
486
|
+
/*
|
|
487
|
+
A solid pack is one compressed object containing many small files.
|
|
488
|
+
|
|
489
|
+
Asking the single-file route for every member made a large restore
|
|
490
|
+
fetch and decompress the same pack thousands of times. The files
|
|
491
|
+
listing already gives us the pack id and each member's exact
|
|
492
|
+
window, so fetch the decoded pack once, verify it, and materialise
|
|
493
|
+
every member before releasing the bounded section-sized buffer.
|
|
494
|
+
*/
|
|
495
|
+
const reply = await this.request(`/v1/repositories/${repositoryId}/objects/${packId}`);
|
|
496
|
+
const body = Buffer.from(await reply.arrayBuffer());
|
|
497
|
+
const packDigest = (0, node_crypto_1.createHash)("sha256").update(body).digest("hex");
|
|
498
|
+
const expectedPackDigest = reply.headers.get("x-coderook-sha256");
|
|
499
|
+
if (expectedPackDigest && packDigest !== expectedPackDigest) {
|
|
500
|
+
throw new Error("A solid pack did not arrive intact");
|
|
501
|
+
}
|
|
502
|
+
for (const member of packed.get(packId) ?? []) {
|
|
503
|
+
this.check();
|
|
504
|
+
const memberParts = member.path.split("/").filter(Boolean);
|
|
505
|
+
if (memberParts.some((part) => part === ".." || part.includes("\0"))) {
|
|
506
|
+
throw new Error(`That version contains an unsafe path: ${member.path}`);
|
|
507
|
+
}
|
|
508
|
+
const offset = member.pack.offset;
|
|
509
|
+
const length = member.pack.length;
|
|
510
|
+
if (offset < 0 || length < 0 || offset + length > body.byteLength) {
|
|
511
|
+
throw new Error(`${member.path} points outside its solid pack`);
|
|
512
|
+
}
|
|
513
|
+
const contents = body.subarray(offset, offset + length);
|
|
514
|
+
const digest = (0, node_crypto_1.createHash)("sha256").update(contents).digest("hex");
|
|
515
|
+
if (member.sha256 && digest !== member.sha256) {
|
|
516
|
+
throw new Error(`${member.path} did not arrive intact`);
|
|
517
|
+
}
|
|
518
|
+
const full = node_path_1.default.join(staging, ...memberParts);
|
|
519
|
+
await (0, promises_1.mkdir)(node_path_1.default.dirname(full), { recursive: true });
|
|
520
|
+
await (0, promises_1.writeFile)(full, contents);
|
|
521
|
+
manifest[member.path] = digest;
|
|
522
|
+
written += 1;
|
|
523
|
+
bytes += member.sourceSize;
|
|
524
|
+
report({
|
|
525
|
+
files: written,
|
|
526
|
+
totalFiles: files.length,
|
|
527
|
+
bytes,
|
|
528
|
+
totalBytes,
|
|
529
|
+
path: member.path,
|
|
530
|
+
percent: underway(bytes, totalBytes),
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
restoredPacks.add(packId);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
340
536
|
const full = node_path_1.default.join(staging, ...parts);
|
|
341
537
|
await (0, promises_1.mkdir)(node_path_1.default.dirname(full), { recursive: true });
|
|
342
538
|
const digest = await this.fetchInto(repositoryId, versionId, file, full);
|
|
@@ -28,6 +28,7 @@ const solid_js_1 = require("./solid.js");
|
|
|
28
28
|
const retry_js_1 = require("./retry.js");
|
|
29
29
|
const staging_js_1 = require("./staging.js");
|
|
30
30
|
const delta_js_1 = require("../shared/delta.js");
|
|
31
|
+
const telemetry_js_1 = require("../shared/telemetry.js");
|
|
31
32
|
/** Above this the API insists on a multipart session. */
|
|
32
33
|
const DIRECT_LIMIT = 95 * 1024 * 1024;
|
|
33
34
|
/*
|
|
@@ -110,6 +111,17 @@ class Uploader {
|
|
|
110
111
|
deltaProtocol = null;
|
|
111
112
|
/** Batch delta is separate so V2 single-file support remains compatible. */
|
|
112
113
|
deltaBatchSupported = null;
|
|
114
|
+
microchunkSupported = null;
|
|
115
|
+
/** One shared preflight, so parallel upload lanes cannot race /health. */
|
|
116
|
+
capabilityRequest = null;
|
|
117
|
+
telemetry = new telemetry_js_1.RepositoryTelemetry();
|
|
118
|
+
/** Planning runs the real partition/compression choices without body I/O. */
|
|
119
|
+
planning = false;
|
|
120
|
+
plannedObjects = new Map();
|
|
121
|
+
plannedQuote = null;
|
|
122
|
+
planningCreatedRepositoryId = null;
|
|
123
|
+
/** Attached to every object write after the server admits the exact plan. */
|
|
124
|
+
activeQuoteId = null;
|
|
113
125
|
constructor(credentials) {
|
|
114
126
|
this.credentials = credentials;
|
|
115
127
|
}
|
|
@@ -143,8 +155,13 @@ class Uploader {
|
|
|
143
155
|
// Its own cancellation first: `worthRetrying` knows nothing of it.
|
|
144
156
|
if (error instanceof UploadCancelled)
|
|
145
157
|
throw error;
|
|
158
|
+
// Completing a multipart upload changes server state. Repeating it can
|
|
159
|
+
// replace the useful first failure with "upload is not active".
|
|
160
|
+
if (init.retryable === false)
|
|
161
|
+
throw error;
|
|
146
162
|
if (attempt >= retry_js_1.RETRY_ATTEMPTS || !(0, retry_js_1.worthRetrying)(error))
|
|
147
163
|
throw error;
|
|
164
|
+
this.telemetry.retry();
|
|
148
165
|
await this.pause(wait);
|
|
149
166
|
wait *= 2;
|
|
150
167
|
}
|
|
@@ -169,6 +186,7 @@ class Uploader {
|
|
|
169
186
|
}
|
|
170
187
|
/** A single attempt. Everything about repeating it lives in `call`. */
|
|
171
188
|
async attempt(route, init) {
|
|
189
|
+
const started = performance.now();
|
|
172
190
|
const token = await this.credentials.token();
|
|
173
191
|
if (!token)
|
|
174
192
|
throw new Error("Sign in again before uploading");
|
|
@@ -179,12 +197,17 @@ class Uploader {
|
|
|
179
197
|
authorization: `Bearer ${token}`,
|
|
180
198
|
"user-agent": "CodeRook/0.1",
|
|
181
199
|
...(0, identify_js_1.clientHeaders)(),
|
|
200
|
+
...(this.activeQuoteId
|
|
201
|
+
? { "x-coderook-upload-quote": this.activeQuoteId }
|
|
202
|
+
: {}),
|
|
182
203
|
...(init.contentType ? { "content-type": init.contentType } : {}),
|
|
183
204
|
},
|
|
184
205
|
body: init.body,
|
|
185
206
|
signal: this.controller.signal,
|
|
186
207
|
});
|
|
187
208
|
const text = await response.text();
|
|
209
|
+
this.telemetry.request(route.split("?")[0] ?? route, init.body, new TextEncoder().encode(text).byteLength, performance.now() - started);
|
|
210
|
+
this.telemetry.memory(process.memoryUsage().rss);
|
|
188
211
|
if (!response.ok) {
|
|
189
212
|
/*
|
|
190
213
|
Parsed only when it looks like JSON. An edge failure serves an HTML
|
|
@@ -229,7 +252,78 @@ class Uploader {
|
|
|
229
252
|
throw new Error(`${route} returned a malformed reply`);
|
|
230
253
|
}
|
|
231
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* Perform the complete local compaction pass and ask the service whether its
|
|
257
|
+
* exact physical result fits. No object body is sent by this method.
|
|
258
|
+
*/
|
|
259
|
+
async plan(request, report) {
|
|
260
|
+
this.planning = true;
|
|
261
|
+
this.activeQuoteId = null;
|
|
262
|
+
this.plannedQuote = null;
|
|
263
|
+
this.planningCreatedRepositoryId = null;
|
|
264
|
+
this.plannedObjects.clear();
|
|
265
|
+
try {
|
|
266
|
+
await this.runPass(request, report);
|
|
267
|
+
const quote = this.plannedQuote;
|
|
268
|
+
if (!quote)
|
|
269
|
+
throw new Error("Upload planning did not finish");
|
|
270
|
+
return {
|
|
271
|
+
...quote,
|
|
272
|
+
repositoryCreated: this.planningCreatedRepositoryId === quote.repositoryId,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
if (this.planningCreatedRepositoryId) {
|
|
277
|
+
await this.call(`/v1/repositories/${this.planningCreatedRepositoryId}`, { method: "DELETE" }).catch(() => undefined);
|
|
278
|
+
}
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
this.planning = false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/** Release a review quote, and optionally its newly-created empty project. */
|
|
286
|
+
async cancelPlan(plan, removeCreatedRepository = false) {
|
|
287
|
+
await this.call(`/v1/repositories/${plan.repositoryId}/uploads/preflight/${plan.quoteId}`, { method: "DELETE" });
|
|
288
|
+
if (removeCreatedRepository && plan.repositoryCreated) {
|
|
289
|
+
await this.call(`/v1/repositories/${plan.repositoryId}`, { method: "DELETE" });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/** Plan first, then execute only the admitted immutable object set. */
|
|
232
293
|
async run(request, report) {
|
|
294
|
+
const plan = await this.plan(request, report);
|
|
295
|
+
return this.execute(request, plan, report);
|
|
296
|
+
}
|
|
297
|
+
/** Execute a still-active quote produced by {@link plan}. */
|
|
298
|
+
async execute(request, plan, report) {
|
|
299
|
+
this.activeQuoteId = plan.quoteId;
|
|
300
|
+
try {
|
|
301
|
+
return await this.runPass({ ...request, repositoryId: plan.repositoryId }, report);
|
|
302
|
+
}
|
|
303
|
+
finally {
|
|
304
|
+
this.activeQuoteId = null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
rememberObject(definition) {
|
|
308
|
+
this.plannedObjects.set(definition.sha256, definition);
|
|
309
|
+
// Only used to let the planning pass build an in-memory candidate. It is
|
|
310
|
+
// never published or sent to the service.
|
|
311
|
+
return `00000000-0000-4000-8000-${definition.sha256.slice(0, 12)}`;
|
|
312
|
+
}
|
|
313
|
+
definition(sha256, size, mediaType, encoded, kind = "chunk", repositoryRole = "chunk") {
|
|
314
|
+
return {
|
|
315
|
+
sha256,
|
|
316
|
+
size,
|
|
317
|
+
storedSize: encoded.body.byteLength,
|
|
318
|
+
storedSha256: encoded.encoding === "gzip" ? encoded.storedSha256 : sha256,
|
|
319
|
+
mediaType,
|
|
320
|
+
kind,
|
|
321
|
+
repositoryRole,
|
|
322
|
+
encoding: encoded.encoding,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
async runPass(request, report) {
|
|
326
|
+
this.telemetry = new telemetry_js_1.RepositoryTelemetry();
|
|
233
327
|
// A version is a snapshot, not a delta, so it has to name every file in
|
|
234
328
|
// the project — not merely the ones being sent this time. Anything
|
|
235
329
|
// unchanged keeps the object the previous version already pointed at.
|
|
@@ -293,7 +387,23 @@ class Uploader {
|
|
|
293
387
|
*/
|
|
294
388
|
const onDisk = new Set(everything.map((file) => file.path));
|
|
295
389
|
const deletions = new Set(request.deletions ?? []);
|
|
390
|
+
const readFailures = new Map();
|
|
296
391
|
const vanished = [...ticked].filter((chosen) => !onDisk.has(chosen) && !deletions.has(chosen));
|
|
392
|
+
/*
|
|
393
|
+
A caller may still hold a selection drawn before the rules changed. If
|
|
394
|
+
the file is on disk but absent from the filtered survey, it is excluded,
|
|
395
|
+
not unreadable. Naming that distinction matters: closing applications
|
|
396
|
+
cannot fix an ignore rule, while changing or removing the rule can.
|
|
397
|
+
*/
|
|
398
|
+
for (const chosen of vanished) {
|
|
399
|
+
try {
|
|
400
|
+
await (0, promises_1.stat)(node_path_1.default.join(request.localPath, chosen));
|
|
401
|
+
readFailures.set(chosen, "excluded by the project's ignore rules");
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
readFailures.set(chosen, error instanceof Error ? error.message : String(error));
|
|
405
|
+
}
|
|
406
|
+
}
|
|
297
407
|
// 1. Measure and hash. This is what makes an unchanged file free.
|
|
298
408
|
const declarations = [];
|
|
299
409
|
let totalBytes = 0;
|
|
@@ -304,10 +414,11 @@ class Uploader {
|
|
|
304
414
|
try {
|
|
305
415
|
size = (await (0, promises_1.stat)(full)).size;
|
|
306
416
|
}
|
|
307
|
-
catch {
|
|
417
|
+
catch (error) {
|
|
308
418
|
// Unreadable now, though it was listed a moment ago. Skipping it
|
|
309
419
|
// would publish a version quietly missing a file the person chose.
|
|
310
420
|
vanished.push(file.path);
|
|
421
|
+
readFailures.set(file.path, error instanceof Error ? error.message : String(error));
|
|
311
422
|
continue;
|
|
312
423
|
}
|
|
313
424
|
report({
|
|
@@ -337,8 +448,9 @@ class Uploader {
|
|
|
337
448
|
try {
|
|
338
449
|
digest = await (0, profile_js_1.timed)("hash files", () => digestOf(full));
|
|
339
450
|
}
|
|
340
|
-
catch {
|
|
451
|
+
catch (error) {
|
|
341
452
|
vanished.push(file.path);
|
|
453
|
+
readFailures.set(file.path, error instanceof Error ? error.message : String(error));
|
|
342
454
|
continue;
|
|
343
455
|
}
|
|
344
456
|
declarations.push({
|
|
@@ -380,6 +492,8 @@ class Uploader {
|
|
|
380
492
|
}),
|
|
381
493
|
});
|
|
382
494
|
repositoryId = created.id;
|
|
495
|
+
if (this.planning)
|
|
496
|
+
this.planningCreatedRepositoryId = created.id;
|
|
383
497
|
}
|
|
384
498
|
catch (error) {
|
|
385
499
|
// The account already has a project of this name — which happens
|
|
@@ -391,6 +505,10 @@ class Uploader {
|
|
|
391
505
|
repositoryId = existing;
|
|
392
506
|
}
|
|
393
507
|
}
|
|
508
|
+
// Learn the wire contract once, before the two section pipelines begin.
|
|
509
|
+
// Otherwise their first feature questions race each other and a busy
|
|
510
|
+
// service can make one lane cache a false "unsupported" answer.
|
|
511
|
+
await this.serviceCapabilities();
|
|
394
512
|
/*
|
|
395
513
|
Sent in sections, not in one attempt.
|
|
396
514
|
|
|
@@ -766,7 +884,9 @@ class Uploader {
|
|
|
766
884
|
whole: cutting it up would trade one request for several and save
|
|
767
885
|
nothing.
|
|
768
886
|
*/
|
|
769
|
-
const chunkProfile = (
|
|
887
|
+
const chunkProfile = (await this.microchunkAllowed())
|
|
888
|
+
? (0, chunking_js_1.microchunkProfileForFileSize)(declaration.size)
|
|
889
|
+
: (0, chunking_js_1.profileForFileSize)(declaration.size);
|
|
770
890
|
if (chunkProfile &&
|
|
771
891
|
declaration.size >= CHUNK_THRESHOLD &&
|
|
772
892
|
(await this.chunkingAllowed())) {
|
|
@@ -880,6 +1000,41 @@ class Uploader {
|
|
|
880
1000
|
}
|
|
881
1001
|
};
|
|
882
1002
|
await Promise.all([pipeline(batchy), pipeline(heavy)]);
|
|
1003
|
+
if (this.planning) {
|
|
1004
|
+
report({
|
|
1005
|
+
stage: "publish",
|
|
1006
|
+
files: declarations.length,
|
|
1007
|
+
totalFiles: declarations.length,
|
|
1008
|
+
bytes: sentBytes,
|
|
1009
|
+
totalBytes,
|
|
1010
|
+
path: "Checking storage and monthly allowance",
|
|
1011
|
+
percent: 96,
|
|
1012
|
+
bytesPerSecond: 0,
|
|
1013
|
+
});
|
|
1014
|
+
this.plannedQuote = await this.call(`/v1/repositories/${repositoryId}/uploads/preflight`, {
|
|
1015
|
+
method: "POST",
|
|
1016
|
+
contentType: "application/json",
|
|
1017
|
+
body: JSON.stringify({
|
|
1018
|
+
sourceBytes: totalBytes,
|
|
1019
|
+
excludedBytes: 0,
|
|
1020
|
+
objects: [...this.plannedObjects.values()],
|
|
1021
|
+
}),
|
|
1022
|
+
});
|
|
1023
|
+
return {
|
|
1024
|
+
repositoryId,
|
|
1025
|
+
versionId: "",
|
|
1026
|
+
sequence: 0,
|
|
1027
|
+
sourceBytes: totalBytes,
|
|
1028
|
+
storedBytes: this.plannedQuote.compactedBytes,
|
|
1029
|
+
sentBytes: this.plannedQuote.chargeableBytes,
|
|
1030
|
+
sentFiles: Object.values(this.plannedQuote.objects).filter((object) => object.needsUpload).length,
|
|
1031
|
+
reusedFiles: reused.length,
|
|
1032
|
+
alreadyStoredFiles: 0,
|
|
1033
|
+
manifest: {},
|
|
1034
|
+
local: {},
|
|
1035
|
+
telemetry: this.telemetry.snapshot(),
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
883
1038
|
// 4. Name the version, which is what makes the upload visible.
|
|
884
1039
|
this.check();
|
|
885
1040
|
report({
|
|
@@ -952,6 +1107,11 @@ class Uploader {
|
|
|
952
1107
|
if (dropped.length) {
|
|
953
1108
|
const shown = dropped.slice(0, 5).join(", ");
|
|
954
1109
|
const rest = dropped.length > 5 ? ` and ${dropped.length - 5} more` : "";
|
|
1110
|
+
const details = dropped
|
|
1111
|
+
.map((name) => readFailures.get(name) ? `${name}: ${readFailures.get(name)}` : "")
|
|
1112
|
+
.filter(Boolean)
|
|
1113
|
+
.slice(0, 3)
|
|
1114
|
+
.join("; ");
|
|
955
1115
|
/*
|
|
956
1116
|
Say what to do about it. Refusing is right — a version quietly missing
|
|
957
1117
|
a file somebody chose is the one failure a backup tool must never have
|
|
@@ -960,10 +1120,14 @@ class Uploader {
|
|
|
960
1120
|
holds them or unticking them. Almost every case is a file another
|
|
961
1121
|
program has open, so that is what it says.
|
|
962
1122
|
*/
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
`
|
|
966
|
-
|
|
1123
|
+
const excluded = dropped.some((name) => readFailures.get(name)?.includes("ignore rules"));
|
|
1124
|
+
throw new Error(`${dropped.length} selected file${dropped.length === 1 ? "" : "s"} could not be included, ` +
|
|
1125
|
+
`so nothing was saved: ${shown}${rest}. ` +
|
|
1126
|
+
(excluded
|
|
1127
|
+
? `At least one is excluded by the project's ignore rules. Change the rules or untick it and try again. `
|
|
1128
|
+
: `Another program may have a file open. Close it or untick the file and try again. `) +
|
|
1129
|
+
`Nothing was changed on your account.` +
|
|
1130
|
+
(details ? ` Details: ${details}` : ""));
|
|
967
1131
|
}
|
|
968
1132
|
const sourceBytes = contents.reduce((total, item) => total + item.sourceSize, 0);
|
|
969
1133
|
const storedBytes = contents.reduce((total, item) => total + item.storedSize, 0);
|
|
@@ -1067,6 +1231,7 @@ class Uploader {
|
|
|
1067
1231
|
reusedFiles: reused.length,
|
|
1068
1232
|
/** Selected, but the service already held the content. */
|
|
1069
1233
|
alreadyStoredFiles: alreadyOnAccount,
|
|
1234
|
+
telemetry: this.telemetry.snapshot(),
|
|
1070
1235
|
// A file that kept its old object records the digest of *that* copy,
|
|
1071
1236
|
// not of the file on disk, so an unticked edit is still pending next
|
|
1072
1237
|
// time rather than looking as though it had been saved.
|
|
@@ -1203,6 +1368,30 @@ class Uploader {
|
|
|
1203
1368
|
if (!built)
|
|
1204
1369
|
return null;
|
|
1205
1370
|
const packed = this.frameSolidPack(built);
|
|
1371
|
+
if (this.planning) {
|
|
1372
|
+
const objectId = this.rememberObject({
|
|
1373
|
+
sha256: built.sha256,
|
|
1374
|
+
size: built.size,
|
|
1375
|
+
storedSize: built.body.byteLength,
|
|
1376
|
+
storedSha256: built.storedSha256,
|
|
1377
|
+
mediaType: "application/octet-stream",
|
|
1378
|
+
kind: "solid_pack",
|
|
1379
|
+
repositoryRole: "bundle",
|
|
1380
|
+
encoding: "gzip",
|
|
1381
|
+
});
|
|
1382
|
+
const placed = new Map();
|
|
1383
|
+
for (const member of built.members) {
|
|
1384
|
+
placed.set(member.sha256, {
|
|
1385
|
+
packObjectId: objectId,
|
|
1386
|
+
offset: member.offset,
|
|
1387
|
+
length: member.length,
|
|
1388
|
+
storedSize: built.size
|
|
1389
|
+
? Math.round((member.length / built.size) * built.body.byteLength)
|
|
1390
|
+
: 0,
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
return placed;
|
|
1394
|
+
}
|
|
1206
1395
|
const answer = await this.call(`/v1/repositories/${repositoryId}/objects/pack`, {
|
|
1207
1396
|
method: "POST",
|
|
1208
1397
|
contentType: "application/octet-stream",
|
|
@@ -1319,6 +1508,17 @@ class Uploader {
|
|
|
1319
1508
|
const wireBytes = signatureBytes.byteLength + framed.byteLength;
|
|
1320
1509
|
if (wireBytes >= solidPackBytes * 0.8)
|
|
1321
1510
|
return null;
|
|
1511
|
+
if (this.planning) {
|
|
1512
|
+
const objects = new Map();
|
|
1513
|
+
for (const item of items) {
|
|
1514
|
+
const definition = this.definition(item.declaration.sha256, item.body.byteLength, item.declaration.mediaType, item.encoded);
|
|
1515
|
+
objects.set(item.declaration.sha256, {
|
|
1516
|
+
objectId: this.rememberObject(definition),
|
|
1517
|
+
size: definition.storedSize,
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
return { objects, sentBytes: wireBytes };
|
|
1521
|
+
}
|
|
1322
1522
|
const answer = await this.call(`/v1/repositories/${repositoryId}/objects/delta/batch`, {
|
|
1323
1523
|
method: "POST",
|
|
1324
1524
|
contentType: "application/octet-stream",
|
|
@@ -1404,6 +1604,14 @@ class Uploader {
|
|
|
1404
1604
|
const wireBytes = signatureBytes.byteLength + framed.byteLength;
|
|
1405
1605
|
if (wireBytes >= ordinary.body.byteLength * 0.8)
|
|
1406
1606
|
return null;
|
|
1607
|
+
if (this.planning) {
|
|
1608
|
+
const definition = this.definition(declaration.sha256, target.byteLength, declaration.mediaType, ordinary);
|
|
1609
|
+
return {
|
|
1610
|
+
objectId: this.rememberObject(definition),
|
|
1611
|
+
storedSize: definition.storedSize,
|
|
1612
|
+
sentBytes: wireBytes,
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1407
1615
|
const answer = await this.call(`/v1/repositories/${repositoryId}/objects/delta`, {
|
|
1408
1616
|
method: "POST",
|
|
1409
1617
|
contentType: "application/octet-stream",
|
|
@@ -1477,6 +1685,16 @@ class Uploader {
|
|
|
1477
1685
|
packed.set(item.encoded.body, at);
|
|
1478
1686
|
at += item.encoded.body.byteLength;
|
|
1479
1687
|
}
|
|
1688
|
+
if (this.planning) {
|
|
1689
|
+
for (const item of items) {
|
|
1690
|
+
const definition = this.definition(item.declaration.sha256, item.body.byteLength, item.declaration.mediaType, item.encoded);
|
|
1691
|
+
landed.set(item.declaration.sha256, {
|
|
1692
|
+
objectId: this.rememberObject(definition),
|
|
1693
|
+
size: definition.storedSize,
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
return landed;
|
|
1697
|
+
}
|
|
1480
1698
|
const answer = await this.call(`/v1/repositories/${repositoryId}/objects/batch`, {
|
|
1481
1699
|
method: "POST",
|
|
1482
1700
|
contentType: "application/octet-stream",
|
|
@@ -1687,6 +1905,16 @@ class Uploader {
|
|
|
1687
1905
|
`&logicalSize=${piece.length}` +
|
|
1688
1906
|
`&storedSha256=${encoded.storedSha256}`
|
|
1689
1907
|
: `?kind=chunk&role=chunk`;
|
|
1908
|
+
if (this.planning) {
|
|
1909
|
+
const definition = this.definition(digest, piece.length, declaration.mediaType, encoded);
|
|
1910
|
+
note("sent", encoded.body.byteLength);
|
|
1911
|
+
chunks[at] = {
|
|
1912
|
+
objectId: this.rememberObject(definition),
|
|
1913
|
+
sourceSize: piece.length,
|
|
1914
|
+
storedSize: definition.storedSize,
|
|
1915
|
+
};
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1690
1918
|
const stored = await this.call(`/v1/repositories/${repositoryId}/objects/${digest}${query}`, {
|
|
1691
1919
|
method: "PUT",
|
|
1692
1920
|
contentType: "application/octet-stream",
|
|
@@ -1728,6 +1956,13 @@ class Uploader {
|
|
|
1728
1956
|
`&logicalSize=${body.length}` +
|
|
1729
1957
|
`&storedSha256=${encoded.storedSha256}`
|
|
1730
1958
|
: `?kind=chunk&role=chunk`;
|
|
1959
|
+
if (this.planning) {
|
|
1960
|
+
const definition = this.definition(digest, body.length, declaration.mediaType, encoded);
|
|
1961
|
+
return {
|
|
1962
|
+
objectId: this.rememberObject(definition),
|
|
1963
|
+
size: definition.storedSize,
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1731
1966
|
/*
|
|
1732
1967
|
The answer carries both sizes and they are not the same thing: `size` is
|
|
1733
1968
|
the file's own length, `storedSize` is what the service actually keeps.
|
|
@@ -1768,6 +2003,12 @@ class Uploader {
|
|
|
1768
2003
|
this.packingSupported = (await this.serviceFeatures()).includes("solid-packs");
|
|
1769
2004
|
return this.packingSupported;
|
|
1770
2005
|
}
|
|
2006
|
+
async microchunkAllowed() {
|
|
2007
|
+
if (this.microchunkSupported !== null)
|
|
2008
|
+
return this.microchunkSupported;
|
|
2009
|
+
this.microchunkSupported = (await this.serviceFeatures()).includes("microchunk-map-v1");
|
|
2010
|
+
return this.microchunkSupported;
|
|
2011
|
+
}
|
|
1771
2012
|
async deltaVersion() {
|
|
1772
2013
|
if (this.deltaProtocol !== null)
|
|
1773
2014
|
return this.deltaProtocol;
|
|
@@ -1791,40 +2032,57 @@ class Uploader {
|
|
|
1791
2032
|
this.chunkingSupported = (await this.serviceFeatures()).includes("chunked-files");
|
|
1792
2033
|
return this.chunkingSupported;
|
|
1793
2034
|
}
|
|
1794
|
-
/** What /health says this deployment accepts.
|
|
2035
|
+
/** What /health says this deployment accepts. One request per uploader. */
|
|
2036
|
+
async serviceCapabilities() {
|
|
2037
|
+
if (this.capabilityRequest)
|
|
2038
|
+
return this.capabilityRequest;
|
|
2039
|
+
this.capabilityRequest = (async () => {
|
|
2040
|
+
try {
|
|
2041
|
+
const response = await fetch(`${this.credentials.origin()}/health`, {
|
|
2042
|
+
headers: { accept: "application/json", ...(0, identify_js_1.clientHeaders)() },
|
|
2043
|
+
signal: AbortSignal.timeout(8000),
|
|
2044
|
+
});
|
|
2045
|
+
if (!response.ok)
|
|
2046
|
+
throw new Error(`health failed (${response.status})`);
|
|
2047
|
+
const body = (await response.json());
|
|
2048
|
+
return {
|
|
2049
|
+
features: body.features ?? [],
|
|
2050
|
+
contentEncodings: body.contentEncodings ?? ["identity"],
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
catch {
|
|
2054
|
+
/* Unknown is treated as unsupported: never risk a refused upload. */
|
|
2055
|
+
return { features: [], contentEncodings: ["identity"] };
|
|
2056
|
+
}
|
|
2057
|
+
})();
|
|
2058
|
+
return this.capabilityRequest;
|
|
2059
|
+
}
|
|
1795
2060
|
async serviceFeatures() {
|
|
1796
|
-
|
|
1797
|
-
const response = await fetch(`${this.credentials.origin()}/health`, {
|
|
1798
|
-
headers: { accept: "application/json", ...(0, identify_js_1.clientHeaders)() },
|
|
1799
|
-
signal: AbortSignal.timeout(8000),
|
|
1800
|
-
});
|
|
1801
|
-
const body = (await response.json());
|
|
1802
|
-
return body.features ?? [];
|
|
1803
|
-
}
|
|
1804
|
-
catch {
|
|
1805
|
-
/* Unknown is treated as unsupported: never risk a refused upload. */
|
|
1806
|
-
return [];
|
|
1807
|
-
}
|
|
2061
|
+
return (await this.serviceCapabilities()).features;
|
|
1808
2062
|
}
|
|
1809
2063
|
async gzipAllowed() {
|
|
1810
2064
|
if (this.gzipSupported !== null)
|
|
1811
2065
|
return this.gzipSupported;
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
headers: { accept: "application/json", ...(0, identify_js_1.clientHeaders)() },
|
|
1815
|
-
signal: AbortSignal.timeout(8000),
|
|
1816
|
-
});
|
|
1817
|
-
const body = (await response.json());
|
|
1818
|
-
this.gzipSupported = Boolean(body.contentEncodings?.includes("gzip"));
|
|
1819
|
-
}
|
|
1820
|
-
catch {
|
|
1821
|
-
/* Unknown is treated as unsupported: never risk a refused upload. */
|
|
1822
|
-
this.gzipSupported = false;
|
|
1823
|
-
}
|
|
2066
|
+
const body = await this.serviceCapabilities();
|
|
2067
|
+
this.gzipSupported = body.contentEncodings.includes("gzip");
|
|
1824
2068
|
return this.gzipSupported;
|
|
1825
2069
|
}
|
|
1826
2070
|
/** Files past the direct limit go up in parts under an upload session. */
|
|
1827
2071
|
async putMultipart(repositoryId, declaration, onOffset) {
|
|
2072
|
+
if (this.planning) {
|
|
2073
|
+
const objectId = this.rememberObject({
|
|
2074
|
+
sha256: declaration.sha256,
|
|
2075
|
+
size: declaration.size,
|
|
2076
|
+
storedSize: declaration.size,
|
|
2077
|
+
storedSha256: declaration.sha256,
|
|
2078
|
+
mediaType: declaration.mediaType,
|
|
2079
|
+
kind: "chunk",
|
|
2080
|
+
repositoryRole: "chunk",
|
|
2081
|
+
encoding: "identity",
|
|
2082
|
+
});
|
|
2083
|
+
onOffset(declaration.size);
|
|
2084
|
+
return { objectId, size: declaration.size };
|
|
2085
|
+
}
|
|
1828
2086
|
const session = await this.call(`/v1/repositories/${repositoryId}/uploads`, {
|
|
1829
2087
|
method: "POST",
|
|
1830
2088
|
contentType: "application/json",
|
|
@@ -1852,7 +2110,12 @@ class Uploader {
|
|
|
1852
2110
|
partNumber += 1;
|
|
1853
2111
|
onOffset(offset);
|
|
1854
2112
|
}
|
|
1855
|
-
return await this.call(`/v1/uploads/${session.uploadSessionId}/complete`, {
|
|
2113
|
+
return await this.call(`/v1/uploads/${session.uploadSessionId}/complete`, {
|
|
2114
|
+
method: "POST",
|
|
2115
|
+
contentType: "application/json",
|
|
2116
|
+
body: "{}",
|
|
2117
|
+
retryable: false,
|
|
2118
|
+
});
|
|
1856
2119
|
}
|
|
1857
2120
|
catch (error) {
|
|
1858
2121
|
// A half-finished session would hold storage forever.
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* means adding a new id, never changing the meaning of an existing one.
|
|
9
9
|
*/
|
|
10
10
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
-
exports.DEFAULT_CHUNK_PROFILE = exports.HUGE_CHUNK_PROFILE = exports.LARGE_CHUNK_PROFILE = exports.MEDIUM_CHUNK_PROFILE = exports.LEGACY_CHUNK_PROFILE = void 0;
|
|
11
|
+
exports.MICRO_CHUNK_PROFILE = exports.DEFAULT_CHUNK_PROFILE = exports.HUGE_CHUNK_PROFILE = exports.LARGE_CHUNK_PROFILE = exports.MEDIUM_CHUNK_PROFILE = exports.LEGACY_CHUNK_PROFILE = void 0;
|
|
12
12
|
exports.profileForFileSize = profileForFileSize;
|
|
13
|
+
exports.microchunkProfileForFileSize = microchunkProfileForFileSize;
|
|
13
14
|
exports.cutPoints = cutPoints;
|
|
14
15
|
exports.chunkBoundaries = chunkBoundaries;
|
|
15
16
|
exports.manifestChunking = manifestChunking;
|
|
@@ -63,6 +64,20 @@ exports.HUGE_CHUNK_PROFILE = Object.freeze({
|
|
|
63
64
|
looseMask: 0x003f_ffff,
|
|
64
65
|
});
|
|
65
66
|
exports.DEFAULT_CHUNK_PROFILE = exports.HUGE_CHUNK_PROFILE;
|
|
67
|
+
/**
|
|
68
|
+
* Fine-grained large-file transport. It uses the same portable FastCDC
|
|
69
|
+
* boundary function but keeps edit amplification near one MiB instead of
|
|
70
|
+
* four to eight MiB. Existing Versions retain their recorded chunk objects.
|
|
71
|
+
*/
|
|
72
|
+
exports.MICRO_CHUNK_PROFILE = Object.freeze({
|
|
73
|
+
id: "fastcdc-v3-micro",
|
|
74
|
+
algorithm: "fastcdc-v2",
|
|
75
|
+
minSize: 256 * KIB,
|
|
76
|
+
targetSize: 1 * MIB,
|
|
77
|
+
maxSize: 4 * MIB,
|
|
78
|
+
strictMask: 0x001f_ffff,
|
|
79
|
+
looseMask: 0x0007_ffff,
|
|
80
|
+
});
|
|
66
81
|
/** The profile repository uploads use for a file of `size` bytes. */
|
|
67
82
|
function profileForFileSize(size) {
|
|
68
83
|
if (!Number.isFinite(size) || size < 0) {
|
|
@@ -76,6 +91,11 @@ function profileForFileSize(size) {
|
|
|
76
91
|
return exports.LARGE_CHUNK_PROFILE;
|
|
77
92
|
return exports.HUGE_CHUNK_PROFILE;
|
|
78
93
|
}
|
|
94
|
+
/** New-write profile when the service advertises the microchunk map. */
|
|
95
|
+
function microchunkProfileForFileSize(size) {
|
|
96
|
+
const legacy = profileForFileSize(size);
|
|
97
|
+
return legacy ? exports.MICRO_CHUNK_PROFILE : null;
|
|
98
|
+
}
|
|
79
99
|
const GEAR = (() => {
|
|
80
100
|
const table = new Uint32Array(256);
|
|
81
101
|
let seed = 0x9e3779b9;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* The storage representation decision is a protocol rule, not a desktop UI
|
|
4
|
+
* preference. Keep the cheap, deterministic part of it in a runtime-neutral
|
|
5
|
+
* module so Desktop, CLI (through Desktop's uploader), Website and the Worker
|
|
6
|
+
* make the same decision for the same object.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.WORTHWHILE_COMPRESSION_RATIO = exports.SMALLEST_WORTH_COMPRESSING = void 0;
|
|
10
|
+
exports.looksCompressed = looksCompressed;
|
|
11
|
+
exports.shouldTryGzip = shouldTryGzip;
|
|
12
|
+
exports.gzipIsWorthKeeping = gzipIsWorthKeeping;
|
|
13
|
+
const ALREADY_COMPRESSED = new Set([
|
|
14
|
+
".7z", ".aac", ".avi", ".br", ".bz2", ".cbx", ".docx", ".flac", ".gif",
|
|
15
|
+
".gz", ".heic", ".jpeg", ".jpg", ".jxl", ".m4a", ".m4v", ".mkv", ".mov",
|
|
16
|
+
".mp3", ".mp4", ".odp", ".ods", ".odt", ".ogg", ".opus", ".pdf", ".png",
|
|
17
|
+
".pptx", ".rar", ".safetensors", ".webm", ".webp", ".xlsx", ".xz", ".zip",
|
|
18
|
+
".zst",
|
|
19
|
+
]);
|
|
20
|
+
exports.SMALLEST_WORTH_COMPRESSING = 4 * 1024;
|
|
21
|
+
exports.WORTHWHILE_COMPRESSION_RATIO = 0.95;
|
|
22
|
+
function looksCompressed(relativePath) {
|
|
23
|
+
const dot = relativePath.lastIndexOf(".");
|
|
24
|
+
if (dot < 0)
|
|
25
|
+
return false;
|
|
26
|
+
return ALREADY_COMPRESSED.has(relativePath.slice(dot).toLowerCase());
|
|
27
|
+
}
|
|
28
|
+
function shouldTryGzip(relativePath, sourceBytes, allowGzip) {
|
|
29
|
+
return (allowGzip &&
|
|
30
|
+
sourceBytes >= exports.SMALLEST_WORTH_COMPRESSING &&
|
|
31
|
+
!looksCompressed(relativePath));
|
|
32
|
+
}
|
|
33
|
+
function gzipIsWorthKeeping(sourceBytes, compressedBytes) {
|
|
34
|
+
return compressedBytes < sourceBytes * exports.WORTHWHILE_COMPRESSION_RATIO;
|
|
35
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RepositoryTelemetry = void 0;
|
|
4
|
+
const byteLength = (body) => typeof body === "string" ? new TextEncoder().encode(body).byteLength : body?.byteLength ?? 0;
|
|
5
|
+
class RepositoryTelemetry {
|
|
6
|
+
started = performance.now();
|
|
7
|
+
requests = 0;
|
|
8
|
+
retries = 0;
|
|
9
|
+
requestBodyBytes = 0;
|
|
10
|
+
responseBodyBytes = 0;
|
|
11
|
+
peakMemoryBytes = null;
|
|
12
|
+
routes = new Map();
|
|
13
|
+
retry() {
|
|
14
|
+
this.retries += 1;
|
|
15
|
+
}
|
|
16
|
+
request(route, body, responseBytes, elapsedMs) {
|
|
17
|
+
this.requestBytes(route, byteLength(body), responseBytes, elapsedMs);
|
|
18
|
+
}
|
|
19
|
+
requestBytes(route, sentBytes, responseBytes, elapsedMs) {
|
|
20
|
+
this.requests += 1;
|
|
21
|
+
this.requestBodyBytes += sentBytes;
|
|
22
|
+
this.responseBodyBytes += responseBytes;
|
|
23
|
+
const prior = this.routes.get(route) ?? { requests: 0, sentBytes: 0, receivedBytes: 0, elapsedMs: 0 };
|
|
24
|
+
prior.requests += 1;
|
|
25
|
+
prior.sentBytes += sentBytes;
|
|
26
|
+
prior.receivedBytes += responseBytes;
|
|
27
|
+
prior.elapsedMs += elapsedMs;
|
|
28
|
+
this.routes.set(route, prior);
|
|
29
|
+
}
|
|
30
|
+
memory(bytes) {
|
|
31
|
+
if (bytes === null || bytes === undefined || !Number.isFinite(bytes) || bytes < 0)
|
|
32
|
+
return;
|
|
33
|
+
this.peakMemoryBytes = Math.max(this.peakMemoryBytes ?? 0, bytes);
|
|
34
|
+
}
|
|
35
|
+
snapshot() {
|
|
36
|
+
return {
|
|
37
|
+
elapsedMs: performance.now() - this.started,
|
|
38
|
+
requests: this.requests,
|
|
39
|
+
retries: this.retries,
|
|
40
|
+
requestBodyBytes: this.requestBodyBytes,
|
|
41
|
+
responseBodyBytes: this.responseBodyBytes,
|
|
42
|
+
peakMemoryBytes: this.peakMemoryBytes,
|
|
43
|
+
routes: Object.fromEntries([...this.routes.entries()].sort(([left], [right]) => left.localeCompare(right))),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
exports.RepositoryTelemetry = RepositoryTelemetry;
|