@coderook/cli 0.22.0 → 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/.claude-plugin/plugin.json +1 -1
- 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 +602 -55
- 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/delta.js +772 -0
- package/dist/desktop-app/src/shared/telemetry.js +47 -0
- package/package.json +1 -1
|
@@ -27,6 +27,8 @@ const profile_js_1 = require("./profile.js");
|
|
|
27
27
|
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
|
+
const delta_js_1 = require("../shared/delta.js");
|
|
31
|
+
const telemetry_js_1 = require("../shared/telemetry.js");
|
|
30
32
|
/** Above this the API insists on a multipart session. */
|
|
31
33
|
const DIRECT_LIMIT = 95 * 1024 * 1024;
|
|
32
34
|
/*
|
|
@@ -105,6 +107,21 @@ class Uploader {
|
|
|
105
107
|
chunkingSupported = null;
|
|
106
108
|
/** Null until the service has been asked; see packingAllowed. */
|
|
107
109
|
packingSupported = null;
|
|
110
|
+
/** Null until the service has advertised its best changed-file transport. */
|
|
111
|
+
deltaProtocol = null;
|
|
112
|
+
/** Batch delta is separate so V2 single-file support remains compatible. */
|
|
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;
|
|
108
125
|
constructor(credentials) {
|
|
109
126
|
this.credentials = credentials;
|
|
110
127
|
}
|
|
@@ -138,8 +155,13 @@ class Uploader {
|
|
|
138
155
|
// Its own cancellation first: `worthRetrying` knows nothing of it.
|
|
139
156
|
if (error instanceof UploadCancelled)
|
|
140
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;
|
|
141
162
|
if (attempt >= retry_js_1.RETRY_ATTEMPTS || !(0, retry_js_1.worthRetrying)(error))
|
|
142
163
|
throw error;
|
|
164
|
+
this.telemetry.retry();
|
|
143
165
|
await this.pause(wait);
|
|
144
166
|
wait *= 2;
|
|
145
167
|
}
|
|
@@ -164,6 +186,7 @@ class Uploader {
|
|
|
164
186
|
}
|
|
165
187
|
/** A single attempt. Everything about repeating it lives in `call`. */
|
|
166
188
|
async attempt(route, init) {
|
|
189
|
+
const started = performance.now();
|
|
167
190
|
const token = await this.credentials.token();
|
|
168
191
|
if (!token)
|
|
169
192
|
throw new Error("Sign in again before uploading");
|
|
@@ -174,12 +197,17 @@ class Uploader {
|
|
|
174
197
|
authorization: `Bearer ${token}`,
|
|
175
198
|
"user-agent": "CodeRook/0.1",
|
|
176
199
|
...(0, identify_js_1.clientHeaders)(),
|
|
200
|
+
...(this.activeQuoteId
|
|
201
|
+
? { "x-coderook-upload-quote": this.activeQuoteId }
|
|
202
|
+
: {}),
|
|
177
203
|
...(init.contentType ? { "content-type": init.contentType } : {}),
|
|
178
204
|
},
|
|
179
205
|
body: init.body,
|
|
180
206
|
signal: this.controller.signal,
|
|
181
207
|
});
|
|
182
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);
|
|
183
211
|
if (!response.ok) {
|
|
184
212
|
/*
|
|
185
213
|
Parsed only when it looks like JSON. An edge failure serves an HTML
|
|
@@ -224,7 +252,78 @@ class Uploader {
|
|
|
224
252
|
throw new Error(`${route} returned a malformed reply`);
|
|
225
253
|
}
|
|
226
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. */
|
|
227
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();
|
|
228
327
|
// A version is a snapshot, not a delta, so it has to name every file in
|
|
229
328
|
// the project — not merely the ones being sent this time. Anything
|
|
230
329
|
// unchanged keeps the object the previous version already pointed at.
|
|
@@ -288,7 +387,23 @@ class Uploader {
|
|
|
288
387
|
*/
|
|
289
388
|
const onDisk = new Set(everything.map((file) => file.path));
|
|
290
389
|
const deletions = new Set(request.deletions ?? []);
|
|
390
|
+
const readFailures = new Map();
|
|
291
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
|
+
}
|
|
292
407
|
// 1. Measure and hash. This is what makes an unchanged file free.
|
|
293
408
|
const declarations = [];
|
|
294
409
|
let totalBytes = 0;
|
|
@@ -299,10 +414,11 @@ class Uploader {
|
|
|
299
414
|
try {
|
|
300
415
|
size = (await (0, promises_1.stat)(full)).size;
|
|
301
416
|
}
|
|
302
|
-
catch {
|
|
417
|
+
catch (error) {
|
|
303
418
|
// Unreadable now, though it was listed a moment ago. Skipping it
|
|
304
419
|
// would publish a version quietly missing a file the person chose.
|
|
305
420
|
vanished.push(file.path);
|
|
421
|
+
readFailures.set(file.path, error instanceof Error ? error.message : String(error));
|
|
306
422
|
continue;
|
|
307
423
|
}
|
|
308
424
|
report({
|
|
@@ -332,8 +448,9 @@ class Uploader {
|
|
|
332
448
|
try {
|
|
333
449
|
digest = await (0, profile_js_1.timed)("hash files", () => digestOf(full));
|
|
334
450
|
}
|
|
335
|
-
catch {
|
|
451
|
+
catch (error) {
|
|
336
452
|
vanished.push(file.path);
|
|
453
|
+
readFailures.set(file.path, error instanceof Error ? error.message : String(error));
|
|
337
454
|
continue;
|
|
338
455
|
}
|
|
339
456
|
declarations.push({
|
|
@@ -345,8 +462,15 @@ class Uploader {
|
|
|
345
462
|
});
|
|
346
463
|
totalBytes += size;
|
|
347
464
|
}
|
|
348
|
-
|
|
465
|
+
/*
|
|
466
|
+
A deletion-only save has no file to hash or upload, but it is still a
|
|
467
|
+
real new Version: the retained base minus the explicitly selected path.
|
|
468
|
+
Refusing it here made the object phase look healthy while preventing the
|
|
469
|
+
manifest-only operation that deletion is supposed to be.
|
|
470
|
+
*/
|
|
471
|
+
if (!declarations.length && !deletions.size) {
|
|
349
472
|
throw new Error("Nothing is selected to upload");
|
|
473
|
+
}
|
|
350
474
|
// 2. The project needs somewhere on the account to live.
|
|
351
475
|
this.check();
|
|
352
476
|
let repositoryId = request.repositoryId;
|
|
@@ -368,6 +492,8 @@ class Uploader {
|
|
|
368
492
|
}),
|
|
369
493
|
});
|
|
370
494
|
repositoryId = created.id;
|
|
495
|
+
if (this.planning)
|
|
496
|
+
this.planningCreatedRepositoryId = created.id;
|
|
371
497
|
}
|
|
372
498
|
catch (error) {
|
|
373
499
|
// The account already has a project of this name — which happens
|
|
@@ -379,6 +505,10 @@ class Uploader {
|
|
|
379
505
|
repositoryId = existing;
|
|
380
506
|
}
|
|
381
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();
|
|
382
512
|
/*
|
|
383
513
|
Sent in sections, not in one attempt.
|
|
384
514
|
|
|
@@ -394,6 +524,24 @@ class Uploader {
|
|
|
394
524
|
*/
|
|
395
525
|
const allDeclarations = declarations;
|
|
396
526
|
const declarationByPath = new Map(allDeclarations.map((one) => [one.path, one]));
|
|
527
|
+
/*
|
|
528
|
+
The narrow first use of the delta transport.
|
|
529
|
+
|
|
530
|
+
One changed small file used to miss solid packing (which correctly
|
|
531
|
+
requires a group) and upload its complete compressed bytes. When more
|
|
532
|
+
than one small file changes, the existing pack remains the better
|
|
533
|
+
answer and is deliberately untouched. New files have no receiver base
|
|
534
|
+
and also stay on the existing path.
|
|
535
|
+
*/
|
|
536
|
+
const logicallyChanged = allDeclarations.filter((one) => prior.get(one.path)?.sha256 !== one.sha256);
|
|
537
|
+
const deltaDeclaration = logicallyChanged.length === 1 &&
|
|
538
|
+
request.baseVersionId &&
|
|
539
|
+
prior.has(logicallyChanged[0].path) &&
|
|
540
|
+
logicallyChanged[0].size <= BATCH_FILE_LIMIT &&
|
|
541
|
+
logicallyChanged[0].size <= delta_js_1.DELTA_MAX_FILE_BYTES &&
|
|
542
|
+
prior.get(logicallyChanged[0].path).sourceSize <= delta_js_1.DELTA_MAX_FILE_BYTES
|
|
543
|
+
? logicallyChanged[0]
|
|
544
|
+
: null;
|
|
397
545
|
const sections = (0, staging_js_1.planSections)(allDeclarations.map((one) => ({ path: one.path, size: one.size })));
|
|
398
546
|
(0, profile_js_1.counted)("sections planned", sections.length);
|
|
399
547
|
const uploaded = [];
|
|
@@ -544,7 +692,22 @@ class Uploader {
|
|
|
544
692
|
an object of their own.
|
|
545
693
|
*/
|
|
546
694
|
const packedInto = new Map();
|
|
547
|
-
if (
|
|
695
|
+
if (deltaDeclaration &&
|
|
696
|
+
smallOnes.some((one) => one.path === deltaDeclaration.path)) {
|
|
697
|
+
const held = prior.get(deltaDeclaration.path);
|
|
698
|
+
const placed = await (0, profile_js_1.timed)("send changed-file delta", () => this.putDelta(repositoryId, request.baseVersionId, held, deltaDeclaration));
|
|
699
|
+
if (placed) {
|
|
700
|
+
batched.set(deltaDeclaration.sha256, {
|
|
701
|
+
objectId: placed.objectId,
|
|
702
|
+
size: placed.storedSize,
|
|
703
|
+
});
|
|
704
|
+
sentBytes += deltaDeclaration.size;
|
|
705
|
+
transferred += placed.sentBytes;
|
|
706
|
+
(0, profile_js_1.counted)("files sent as deltas", 1);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
const smallForPacking = smallOnes.filter((one) => !batched.has(one.sha256));
|
|
710
|
+
if (smallForPacking.length > 1) {
|
|
548
711
|
let pack = [];
|
|
549
712
|
let packBytes = 0;
|
|
550
713
|
/*
|
|
@@ -569,6 +732,7 @@ class Uploader {
|
|
|
569
732
|
pack = [];
|
|
570
733
|
packBytes = 0;
|
|
571
734
|
const task = (async () => {
|
|
735
|
+
let exactWireBytes = null;
|
|
572
736
|
try {
|
|
573
737
|
/*
|
|
574
738
|
Compressed together first. This is what the .cbx format is for and
|
|
@@ -577,18 +741,36 @@ class Uploader {
|
|
|
577
741
|
and 30.2% compressed together, because a two-kilobyte file gives a
|
|
578
742
|
compressor no dictionary worth having.
|
|
579
743
|
*/
|
|
580
|
-
const
|
|
581
|
-
|
|
582
|
-
|
|
744
|
+
const packItems = taking.map((item) => ({
|
|
745
|
+
declaration: item.declaration,
|
|
746
|
+
body: item.body,
|
|
747
|
+
}));
|
|
748
|
+
const built = (await this.packingAllowed())
|
|
749
|
+
? await (0, solid_js_1.buildSolidPack)(packItems.map((item) => ({
|
|
750
|
+
sha256: item.declaration.sha256,
|
|
583
751
|
body: item.body,
|
|
584
|
-
})))
|
|
752
|
+
})))
|
|
753
|
+
: null;
|
|
754
|
+
const packedBytes = built ? this.frameSolidPack(built).byteLength : 0;
|
|
755
|
+
const delta = built
|
|
756
|
+
? await (0, profile_js_1.timed)("price changed-file batch", () => this.putDeltaBatch(repositoryId, request.baseVersionId, taking, prior, packedBytes))
|
|
757
|
+
: null;
|
|
758
|
+
if (delta) {
|
|
759
|
+
(0, profile_js_1.counted)("files sent as delta batch", taking.length);
|
|
760
|
+
exactWireBytes = delta.sentBytes;
|
|
761
|
+
for (const [digest, one] of delta.objects)
|
|
762
|
+
batched.set(digest, one);
|
|
763
|
+
}
|
|
764
|
+
const placed = !delta && built
|
|
765
|
+
? await (0, profile_js_1.timed)("send packs", () => this.putPack(repositoryId, packItems, built))
|
|
585
766
|
: null;
|
|
586
767
|
if (placed) {
|
|
587
768
|
(0, profile_js_1.counted)("files sent in packs", taking.length);
|
|
769
|
+
exactWireBytes = packedBytes;
|
|
588
770
|
for (const [digest, one] of placed)
|
|
589
771
|
packedInto.set(digest, one);
|
|
590
772
|
}
|
|
591
|
-
else {
|
|
773
|
+
else if (!delta) {
|
|
592
774
|
/*
|
|
593
775
|
Not worth packing — already-compressed content, or too few files.
|
|
594
776
|
They still travel together, just as separate objects.
|
|
@@ -617,8 +799,11 @@ class Uploader {
|
|
|
617
799
|
if (!batched.has(digest) && !packedInto.has(digest))
|
|
618
800
|
continue;
|
|
619
801
|
sentBytes += item.declaration.size;
|
|
620
|
-
|
|
802
|
+
if (exactWireBytes === null)
|
|
803
|
+
transferred += item.encoded.body.byteLength;
|
|
621
804
|
}
|
|
805
|
+
if (exactWireBytes !== null)
|
|
806
|
+
transferred += exactWireBytes;
|
|
622
807
|
report({
|
|
623
808
|
stage: "upload",
|
|
624
809
|
files: 0,
|
|
@@ -634,7 +819,7 @@ class Uploader {
|
|
|
634
819
|
if (flying.size >= BATCH_LANES)
|
|
635
820
|
await Promise.race(flying);
|
|
636
821
|
};
|
|
637
|
-
for (const declaration of
|
|
822
|
+
for (const declaration of smallForPacking) {
|
|
638
823
|
this.check();
|
|
639
824
|
let body;
|
|
640
825
|
try {
|
|
@@ -699,7 +884,9 @@ class Uploader {
|
|
|
699
884
|
whole: cutting it up would trade one request for several and save
|
|
700
885
|
nothing.
|
|
701
886
|
*/
|
|
702
|
-
const chunkProfile = (
|
|
887
|
+
const chunkProfile = (await this.microchunkAllowed())
|
|
888
|
+
? (0, chunking_js_1.microchunkProfileForFileSize)(declaration.size)
|
|
889
|
+
: (0, chunking_js_1.profileForFileSize)(declaration.size);
|
|
703
890
|
if (chunkProfile &&
|
|
704
891
|
declaration.size >= CHUNK_THRESHOLD &&
|
|
705
892
|
(await this.chunkingAllowed())) {
|
|
@@ -813,6 +1000,41 @@ class Uploader {
|
|
|
813
1000
|
}
|
|
814
1001
|
};
|
|
815
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
|
+
}
|
|
816
1038
|
// 4. Name the version, which is what makes the upload visible.
|
|
817
1039
|
this.check();
|
|
818
1040
|
report({
|
|
@@ -885,6 +1107,11 @@ class Uploader {
|
|
|
885
1107
|
if (dropped.length) {
|
|
886
1108
|
const shown = dropped.slice(0, 5).join(", ");
|
|
887
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("; ");
|
|
888
1115
|
/*
|
|
889
1116
|
Say what to do about it. Refusing is right — a version quietly missing
|
|
890
1117
|
a file somebody chose is the one failure a backup tool must never have
|
|
@@ -893,10 +1120,14 @@ class Uploader {
|
|
|
893
1120
|
holds them or unticking them. Almost every case is a file another
|
|
894
1121
|
program has open, so that is what it says.
|
|
895
1122
|
*/
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
`
|
|
899
|
-
|
|
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}` : ""));
|
|
900
1131
|
}
|
|
901
1132
|
const sourceBytes = contents.reduce((total, item) => total + item.sourceSize, 0);
|
|
902
1133
|
const storedBytes = contents.reduce((total, item) => total + item.storedSize, 0);
|
|
@@ -1000,6 +1231,7 @@ class Uploader {
|
|
|
1000
1231
|
reusedFiles: reused.length,
|
|
1001
1232
|
/** Selected, but the service already held the content. */
|
|
1002
1233
|
alreadyStoredFiles: alreadyOnAccount,
|
|
1234
|
+
telemetry: this.telemetry.snapshot(),
|
|
1003
1235
|
// A file that kept its old object records the digest of *that* copy,
|
|
1004
1236
|
// not of the file on disk, so an unticked edit is still pending next
|
|
1005
1237
|
// time rather than looking as though it had been saved.
|
|
@@ -1128,24 +1360,38 @@ class Uploader {
|
|
|
1128
1360
|
* service would not take it — in which case the caller falls back to sending
|
|
1129
1361
|
* them separately, which is slower and larger but always works.
|
|
1130
1362
|
*/
|
|
1131
|
-
async putPack(repositoryId, items) {
|
|
1132
|
-
const built = await (0, solid_js_1.buildSolidPack)(items.map((item) => ({
|
|
1363
|
+
async putPack(repositoryId, items, alreadyBuilt) {
|
|
1364
|
+
const built = alreadyBuilt ?? await (0, solid_js_1.buildSolidPack)(items.map((item) => ({
|
|
1133
1365
|
sha256: item.declaration.sha256,
|
|
1134
1366
|
body: item.body,
|
|
1135
1367
|
})));
|
|
1136
1368
|
if (!built)
|
|
1137
1369
|
return null;
|
|
1138
|
-
const
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
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
|
+
}
|
|
1149
1395
|
const answer = await this.call(`/v1/repositories/${repositoryId}/objects/pack`, {
|
|
1150
1396
|
method: "POST",
|
|
1151
1397
|
contentType: "application/octet-stream",
|
|
@@ -1174,6 +1420,235 @@ class Uploader {
|
|
|
1174
1420
|
}
|
|
1175
1421
|
return placed;
|
|
1176
1422
|
}
|
|
1423
|
+
/** The exact HTTP body used for the solid-pack alternative. */
|
|
1424
|
+
frameSolidPack(built) {
|
|
1425
|
+
const manifestBytes = new TextEncoder().encode(JSON.stringify({
|
|
1426
|
+
sha256: built.sha256,
|
|
1427
|
+
storedSha256: built.storedSha256,
|
|
1428
|
+
size: built.size,
|
|
1429
|
+
members: built.members,
|
|
1430
|
+
}));
|
|
1431
|
+
const packed = new Uint8Array(4 + manifestBytes.byteLength + built.body.byteLength);
|
|
1432
|
+
new DataView(packed.buffer).setUint32(0, manifestBytes.byteLength, false);
|
|
1433
|
+
packed.set(manifestBytes, 4);
|
|
1434
|
+
packed.set(built.body, 4 + manifestBytes.byteLength);
|
|
1435
|
+
return packed;
|
|
1436
|
+
}
|
|
1437
|
+
/**
|
|
1438
|
+
* Price several independent file deltas against the already-built solid
|
|
1439
|
+
* pack. The batch is optional and may only replace the pack when every
|
|
1440
|
+
* member has an immutable base and the complete two-way wire cost wins by
|
|
1441
|
+
* the same twenty-percent margin used by the single-file transport.
|
|
1442
|
+
*/
|
|
1443
|
+
async putDeltaBatch(repositoryId, baseVersionId, items, prior, solidPackBytes) {
|
|
1444
|
+
if (!baseVersionId ||
|
|
1445
|
+
solidPackBytes <= 0 ||
|
|
1446
|
+
items.length < 2 ||
|
|
1447
|
+
items.length > delta_js_1.DELTA_BATCH_MAX_FILES ||
|
|
1448
|
+
!(await this.deltaBatchAllowed()))
|
|
1449
|
+
return null;
|
|
1450
|
+
let sourceBytes = 0;
|
|
1451
|
+
let signatureEstimate = 6 + items.length * 4;
|
|
1452
|
+
const bases = [];
|
|
1453
|
+
for (const item of items) {
|
|
1454
|
+
const base = prior.get(item.declaration.path);
|
|
1455
|
+
if (!base ||
|
|
1456
|
+
!base.sha256 ||
|
|
1457
|
+
item.declaration.size > delta_js_1.DELTA_MAX_FILE_BYTES ||
|
|
1458
|
+
base.sourceSize > delta_js_1.DELTA_MAX_FILE_BYTES)
|
|
1459
|
+
return null;
|
|
1460
|
+
if ((0, node_crypto_1.createHash)("sha256").update(item.body).digest("hex") !==
|
|
1461
|
+
item.declaration.sha256)
|
|
1462
|
+
return null;
|
|
1463
|
+
sourceBytes += item.declaration.size;
|
|
1464
|
+
if (sourceBytes > delta_js_1.DELTA_BATCH_MAX_SOURCE_BYTES)
|
|
1465
|
+
return null;
|
|
1466
|
+
signatureEstimate += (0, delta_js_1.estimateDeltaSignatureBytes)(base.sourceSize, 2);
|
|
1467
|
+
bases.push(base);
|
|
1468
|
+
}
|
|
1469
|
+
if (signatureEstimate >= solidPackBytes * 0.8)
|
|
1470
|
+
return null;
|
|
1471
|
+
try {
|
|
1472
|
+
const token = await this.credentials.token();
|
|
1473
|
+
if (!token)
|
|
1474
|
+
return null;
|
|
1475
|
+
const response = await fetch(`${this.credentials.origin()}/v1/repositories/${repositoryId}/objects/delta/signatures`, {
|
|
1476
|
+
method: "POST",
|
|
1477
|
+
headers: {
|
|
1478
|
+
accept: "application/vnd.coderook.delta-signature-batch; version=2",
|
|
1479
|
+
authorization: `Bearer ${token}`,
|
|
1480
|
+
"content-type": "application/json",
|
|
1481
|
+
"user-agent": "CodeRook/0.1",
|
|
1482
|
+
...(0, identify_js_1.clientHeaders)(),
|
|
1483
|
+
},
|
|
1484
|
+
body: JSON.stringify({
|
|
1485
|
+
baseVersionId,
|
|
1486
|
+
files: items.map((item, index) => ({
|
|
1487
|
+
path: item.declaration.path,
|
|
1488
|
+
baseSha256: bases[index].sha256,
|
|
1489
|
+
})),
|
|
1490
|
+
}),
|
|
1491
|
+
signal: this.controller.signal,
|
|
1492
|
+
});
|
|
1493
|
+
if (!response.ok)
|
|
1494
|
+
return null;
|
|
1495
|
+
const signatureBytes = new Uint8Array(await response.arrayBuffer());
|
|
1496
|
+
const signatures = (0, delta_js_1.decodeDeltaSignatureBatch)(signatureBytes);
|
|
1497
|
+
if (signatures.length !== items.length)
|
|
1498
|
+
return null;
|
|
1499
|
+
const framed = (0, delta_js_1.encodeDeltaBatchEnvelope)({
|
|
1500
|
+
baseVersionId,
|
|
1501
|
+
entries: items.map((item, index) => ({
|
|
1502
|
+
path: item.declaration.path,
|
|
1503
|
+
sha256: item.declaration.sha256,
|
|
1504
|
+
mediaType: item.declaration.mediaType,
|
|
1505
|
+
patch: (0, delta_js_1.createDeltaPatch)(item.body, signatures[index]),
|
|
1506
|
+
})),
|
|
1507
|
+
});
|
|
1508
|
+
const wireBytes = signatureBytes.byteLength + framed.byteLength;
|
|
1509
|
+
if (wireBytes >= solidPackBytes * 0.8)
|
|
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
|
+
}
|
|
1522
|
+
const answer = await this.call(`/v1/repositories/${repositoryId}/objects/delta/batch`, {
|
|
1523
|
+
method: "POST",
|
|
1524
|
+
contentType: "application/octet-stream",
|
|
1525
|
+
body: framed,
|
|
1526
|
+
});
|
|
1527
|
+
if (answer.objects.length !== items.length)
|
|
1528
|
+
return null;
|
|
1529
|
+
const objects = new Map();
|
|
1530
|
+
for (const object of answer.objects) {
|
|
1531
|
+
if (!items.some((item) => item.declaration.sha256 === object.sha256)) {
|
|
1532
|
+
return null;
|
|
1533
|
+
}
|
|
1534
|
+
objects.set(object.sha256, {
|
|
1535
|
+
objectId: object.objectId,
|
|
1536
|
+
size: object.storedSize,
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
const expectedDigests = new Set(items.map((item) => item.declaration.sha256)).size;
|
|
1540
|
+
return objects.size === expectedDigests
|
|
1541
|
+
? { objects, sentBytes: wireBytes }
|
|
1542
|
+
: null;
|
|
1543
|
+
}
|
|
1544
|
+
catch {
|
|
1545
|
+
return null;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* Try the negotiated receiver-signature transport for one changed file.
|
|
1550
|
+
*
|
|
1551
|
+
* Any refusal, old deployment, stale base, or poor result returns null and
|
|
1552
|
+
* the caller sends the complete object exactly as it did before. The patch
|
|
1553
|
+
* is never a repository object: the service reconstructs and verifies the
|
|
1554
|
+
* complete target before returning the ordinary object id used below.
|
|
1555
|
+
*/
|
|
1556
|
+
async putDelta(repositoryId, baseVersionId, prior, declaration) {
|
|
1557
|
+
const deltaVersion = await this.deltaVersion();
|
|
1558
|
+
if (deltaVersion === 0)
|
|
1559
|
+
return null;
|
|
1560
|
+
let target;
|
|
1561
|
+
try {
|
|
1562
|
+
target = new Uint8Array(await (0, promises_1.readFile)(declaration.full));
|
|
1563
|
+
}
|
|
1564
|
+
catch {
|
|
1565
|
+
return null;
|
|
1566
|
+
}
|
|
1567
|
+
const digest = (0, node_crypto_1.createHash)("sha256").update(target).digest("hex");
|
|
1568
|
+
if (digest !== declaration.sha256)
|
|
1569
|
+
return null;
|
|
1570
|
+
const ordinary = await (0, compress_js_1.encodeForUpload)(target, declaration.path, await this.gzipAllowed());
|
|
1571
|
+
const signatureEstimate = (0, delta_js_1.estimateDeltaSignatureBytes)(prior.sourceSize, deltaVersion);
|
|
1572
|
+
if (signatureEstimate >= ordinary.body.byteLength * 0.8)
|
|
1573
|
+
return null;
|
|
1574
|
+
try {
|
|
1575
|
+
const signatureBytes = await this.deltaSignature(repositoryId, baseVersionId, declaration.path, prior.sha256, deltaVersion);
|
|
1576
|
+
const signature = (0, delta_js_1.parseDeltaSignature)(signatureBytes);
|
|
1577
|
+
const patch = (0, delta_js_1.createDeltaPatch)(target, signature);
|
|
1578
|
+
let framed;
|
|
1579
|
+
if (signature.version === 2) {
|
|
1580
|
+
framed = (0, delta_js_1.encodeDeltaEnvelope)({
|
|
1581
|
+
baseVersionId,
|
|
1582
|
+
path: declaration.path,
|
|
1583
|
+
sha256: declaration.sha256,
|
|
1584
|
+
mediaType: declaration.mediaType,
|
|
1585
|
+
patch,
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
else {
|
|
1589
|
+
const manifest = new TextEncoder().encode(JSON.stringify({
|
|
1590
|
+
baseVersionId,
|
|
1591
|
+
path: declaration.path,
|
|
1592
|
+
baseSha256: prior.sha256,
|
|
1593
|
+
sha256: declaration.sha256,
|
|
1594
|
+
size: target.byteLength,
|
|
1595
|
+
mediaType: declaration.mediaType,
|
|
1596
|
+
}));
|
|
1597
|
+
framed = new Uint8Array(4 + manifest.byteLength + patch.byteLength);
|
|
1598
|
+
new DataView(framed.buffer).setUint32(0, manifest.byteLength, false);
|
|
1599
|
+
framed.set(manifest, 4);
|
|
1600
|
+
framed.set(patch, 4 + manifest.byteLength);
|
|
1601
|
+
}
|
|
1602
|
+
// Count both directions. A patch that only looks small because its
|
|
1603
|
+
// receiver signature was ignored is not an improvement.
|
|
1604
|
+
const wireBytes = signatureBytes.byteLength + framed.byteLength;
|
|
1605
|
+
if (wireBytes >= ordinary.body.byteLength * 0.8)
|
|
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
|
+
}
|
|
1615
|
+
const answer = await this.call(`/v1/repositories/${repositoryId}/objects/delta`, {
|
|
1616
|
+
method: "POST",
|
|
1617
|
+
contentType: "application/octet-stream",
|
|
1618
|
+
body: framed,
|
|
1619
|
+
});
|
|
1620
|
+
return {
|
|
1621
|
+
objectId: answer.objectId,
|
|
1622
|
+
storedSize: answer.storedSize,
|
|
1623
|
+
sentBytes: wireBytes,
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
catch {
|
|
1627
|
+
// This is an optional transport. The complete-object path is authority.
|
|
1628
|
+
return null;
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
async deltaSignature(repositoryId, baseVersionId, filePath, baseSha256, version) {
|
|
1632
|
+
const token = await this.credentials.token();
|
|
1633
|
+
if (!token)
|
|
1634
|
+
throw new Error("Sign in again before uploading");
|
|
1635
|
+
const response = await fetch(`${this.credentials.origin()}/v1/repositories/${repositoryId}/objects/delta/signature`, {
|
|
1636
|
+
method: "POST",
|
|
1637
|
+
headers: {
|
|
1638
|
+
accept: `application/vnd.coderook.delta-signature; version=${version}`,
|
|
1639
|
+
authorization: `Bearer ${token}`,
|
|
1640
|
+
"content-type": "application/json",
|
|
1641
|
+
"user-agent": "CodeRook/0.1",
|
|
1642
|
+
...(0, identify_js_1.clientHeaders)(),
|
|
1643
|
+
},
|
|
1644
|
+
body: JSON.stringify({ baseVersionId, path: filePath, baseSha256 }),
|
|
1645
|
+
signal: this.controller.signal,
|
|
1646
|
+
});
|
|
1647
|
+
if (!response.ok) {
|
|
1648
|
+
throw new Error(`Delta signature failed with HTTP ${response.status}`);
|
|
1649
|
+
}
|
|
1650
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
1651
|
+
}
|
|
1177
1652
|
/**
|
|
1178
1653
|
* Send many small objects in one request.
|
|
1179
1654
|
*
|
|
@@ -1210,6 +1685,16 @@ class Uploader {
|
|
|
1210
1685
|
packed.set(item.encoded.body, at);
|
|
1211
1686
|
at += item.encoded.body.byteLength;
|
|
1212
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
|
+
}
|
|
1213
1698
|
const answer = await this.call(`/v1/repositories/${repositoryId}/objects/batch`, {
|
|
1214
1699
|
method: "POST",
|
|
1215
1700
|
contentType: "application/octet-stream",
|
|
@@ -1420,6 +1905,16 @@ class Uploader {
|
|
|
1420
1905
|
`&logicalSize=${piece.length}` +
|
|
1421
1906
|
`&storedSha256=${encoded.storedSha256}`
|
|
1422
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
|
+
}
|
|
1423
1918
|
const stored = await this.call(`/v1/repositories/${repositoryId}/objects/${digest}${query}`, {
|
|
1424
1919
|
method: "PUT",
|
|
1425
1920
|
contentType: "application/octet-stream",
|
|
@@ -1461,6 +1956,13 @@ class Uploader {
|
|
|
1461
1956
|
`&logicalSize=${body.length}` +
|
|
1462
1957
|
`&storedSha256=${encoded.storedSha256}`
|
|
1463
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
|
+
}
|
|
1464
1966
|
/*
|
|
1465
1967
|
The answer carries both sizes and they are not the same thing: `size` is
|
|
1466
1968
|
the file's own length, `storedSize` is what the service actually keeps.
|
|
@@ -1501,46 +2003,86 @@ class Uploader {
|
|
|
1501
2003
|
this.packingSupported = (await this.serviceFeatures()).includes("solid-packs");
|
|
1502
2004
|
return this.packingSupported;
|
|
1503
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
|
+
}
|
|
2012
|
+
async deltaVersion() {
|
|
2013
|
+
if (this.deltaProtocol !== null)
|
|
2014
|
+
return this.deltaProtocol;
|
|
2015
|
+
const features = await this.serviceFeatures();
|
|
2016
|
+
this.deltaProtocol = features.includes("delta-transport-v2")
|
|
2017
|
+
? 2
|
|
2018
|
+
: features.includes("delta-transport")
|
|
2019
|
+
? 1
|
|
2020
|
+
: 0;
|
|
2021
|
+
return this.deltaProtocol;
|
|
2022
|
+
}
|
|
2023
|
+
async deltaBatchAllowed() {
|
|
2024
|
+
if (this.deltaBatchSupported !== null)
|
|
2025
|
+
return this.deltaBatchSupported;
|
|
2026
|
+
this.deltaBatchSupported = (await this.serviceFeatures()).includes("delta-batch-v2");
|
|
2027
|
+
return this.deltaBatchSupported;
|
|
2028
|
+
}
|
|
1504
2029
|
async chunkingAllowed() {
|
|
1505
2030
|
if (this.chunkingSupported !== null)
|
|
1506
2031
|
return this.chunkingSupported;
|
|
1507
2032
|
this.chunkingSupported = (await this.serviceFeatures()).includes("chunked-files");
|
|
1508
2033
|
return this.chunkingSupported;
|
|
1509
2034
|
}
|
|
1510
|
-
/** 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
|
+
}
|
|
1511
2060
|
async serviceFeatures() {
|
|
1512
|
-
|
|
1513
|
-
const response = await fetch(`${this.credentials.origin()}/health`, {
|
|
1514
|
-
headers: { accept: "application/json", ...(0, identify_js_1.clientHeaders)() },
|
|
1515
|
-
signal: AbortSignal.timeout(8000),
|
|
1516
|
-
});
|
|
1517
|
-
const body = (await response.json());
|
|
1518
|
-
return body.features ?? [];
|
|
1519
|
-
}
|
|
1520
|
-
catch {
|
|
1521
|
-
/* Unknown is treated as unsupported: never risk a refused upload. */
|
|
1522
|
-
return [];
|
|
1523
|
-
}
|
|
2061
|
+
return (await this.serviceCapabilities()).features;
|
|
1524
2062
|
}
|
|
1525
2063
|
async gzipAllowed() {
|
|
1526
2064
|
if (this.gzipSupported !== null)
|
|
1527
2065
|
return this.gzipSupported;
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
headers: { accept: "application/json", ...(0, identify_js_1.clientHeaders)() },
|
|
1531
|
-
signal: AbortSignal.timeout(8000),
|
|
1532
|
-
});
|
|
1533
|
-
const body = (await response.json());
|
|
1534
|
-
this.gzipSupported = Boolean(body.contentEncodings?.includes("gzip"));
|
|
1535
|
-
}
|
|
1536
|
-
catch {
|
|
1537
|
-
/* Unknown is treated as unsupported: never risk a refused upload. */
|
|
1538
|
-
this.gzipSupported = false;
|
|
1539
|
-
}
|
|
2066
|
+
const body = await this.serviceCapabilities();
|
|
2067
|
+
this.gzipSupported = body.contentEncodings.includes("gzip");
|
|
1540
2068
|
return this.gzipSupported;
|
|
1541
2069
|
}
|
|
1542
2070
|
/** Files past the direct limit go up in parts under an upload session. */
|
|
1543
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
|
+
}
|
|
1544
2086
|
const session = await this.call(`/v1/repositories/${repositoryId}/uploads`, {
|
|
1545
2087
|
method: "POST",
|
|
1546
2088
|
contentType: "application/json",
|
|
@@ -1568,7 +2110,12 @@ class Uploader {
|
|
|
1568
2110
|
partNumber += 1;
|
|
1569
2111
|
onOffset(offset);
|
|
1570
2112
|
}
|
|
1571
|
-
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
|
+
});
|
|
1572
2119
|
}
|
|
1573
2120
|
catch (error) {
|
|
1574
2121
|
// A half-finished session would hold storage forever.
|