@coderook/cli 0.29.4 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/cli/src/api.js +46 -1
- package/dist/cli/src/cli.js +118 -7
- package/dist/cli/src/diff_command.js +218 -0
- package/dist/desktop-app/src/main/detect.js +91 -5
- package/dist/desktop-app/src/main/rules.js +21 -3
- package/dist/desktop-app/src/main/transfer_progress.js +109 -0
- package/dist/desktop-app/src/main/upload.js +617 -559
- package/dist/desktop-app/src/main/worktree.js +158 -5
- package/dist/desktop-app/src/shared/merge_diff.js +191 -0
- package/package.json +1 -1
|
@@ -27,6 +27,7 @@ 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 transfer_progress_js_1 = require("./transfer_progress.js");
|
|
30
31
|
const delta_js_1 = require("../shared/delta.js");
|
|
31
32
|
const telemetry_js_1 = require("../shared/telemetry.js");
|
|
32
33
|
/** Above this the API insists on a multipart session. */
|
|
@@ -339,18 +340,16 @@ class Uploader {
|
|
|
339
340
|
encoding: encoded.encoding,
|
|
340
341
|
};
|
|
341
342
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
rule) would otherwise be dropped without being mentioned.
|
|
353
|
-
*/
|
|
343
|
+
/**
|
|
344
|
+
* What this save is going to be about: which files, and what came before.
|
|
345
|
+
*
|
|
346
|
+
* Lifted out of `runPass` because it answers a question of its own and
|
|
347
|
+
* nothing after it changes the answer. The pass around it was one function
|
|
348
|
+
* of twelve hundred lines carrying five stages, which is how a progress
|
|
349
|
+
* report that reset the bar on every section header went unnoticed inside
|
|
350
|
+
* it for as long as it did.
|
|
351
|
+
*/
|
|
352
|
+
async selectFiles(request, report) {
|
|
354
353
|
const rules = request.allowIgnored
|
|
355
354
|
? { shared: "", local: "" }
|
|
356
355
|
: await (0, worktree_js_1.readRules)(request.localPath);
|
|
@@ -458,6 +457,31 @@ class Uploader {
|
|
|
458
457
|
readFailures.set(chosen, error instanceof Error ? error.message : String(error));
|
|
459
458
|
}
|
|
460
459
|
}
|
|
460
|
+
return {
|
|
461
|
+
rules,
|
|
462
|
+
everything,
|
|
463
|
+
unticked,
|
|
464
|
+
ticked,
|
|
465
|
+
prior,
|
|
466
|
+
sending,
|
|
467
|
+
reused,
|
|
468
|
+
deletions,
|
|
469
|
+
readFailures,
|
|
470
|
+
vanished,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Every file's digest and size, which is what makes an unchanged file free.
|
|
475
|
+
*
|
|
476
|
+
* Its own method because it is the one stage that is purely waiting: each
|
|
477
|
+
* file here is an open, a read and a close, and on Windows every open is
|
|
478
|
+
* also an antivirus scan. Keeping it whole and separate is what makes the
|
|
479
|
+
* lane count and the throttled reporting legible rather than buried in the
|
|
480
|
+
* middle of a twelve-hundred-line pass.
|
|
481
|
+
*/
|
|
482
|
+
async hashSelection(request, sending, deletions, readFailures,
|
|
483
|
+
/** Ticked, not on disk, not a deletion — named in the refusal below. */
|
|
484
|
+
vanished, report) {
|
|
461
485
|
// 1. Measure and hash. This is what makes an unchanged file free.
|
|
462
486
|
const declarations = [];
|
|
463
487
|
let totalBytes = 0;
|
|
@@ -594,294 +618,441 @@ class Uploader {
|
|
|
594
618
|
if (!declarations.length && !deletions.size) {
|
|
595
619
|
throw new Error("Nothing is selected to upload");
|
|
596
620
|
}
|
|
597
|
-
|
|
621
|
+
return { declarations, totalBytes };
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* The project this save goes into, made if it does not exist yet.
|
|
625
|
+
*
|
|
626
|
+
* Small enough to inline and worth naming anyway: it is the only step that
|
|
627
|
+
* can create something on the account, and the slug collision below is the
|
|
628
|
+
* reason it cannot simply be a create call.
|
|
629
|
+
*/
|
|
630
|
+
async ensureRepository(request) {
|
|
598
631
|
this.check();
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
}
|
|
621
|
-
catch (error) {
|
|
622
|
-
// The account already has a project of this name — which happens
|
|
623
|
-
// after a reinstall, or when the folder has moved. Saving into it is
|
|
624
|
-
// what was meant; failing would strand the folder.
|
|
625
|
-
const existing = await this.findBySlug(slug);
|
|
626
|
-
if (!existing)
|
|
627
|
-
throw error;
|
|
628
|
-
repositoryId = existing;
|
|
629
|
-
}
|
|
632
|
+
if (request.repositoryId)
|
|
633
|
+
return request.repositoryId;
|
|
634
|
+
const slug = request.projectName
|
|
635
|
+
.trim()
|
|
636
|
+
.toLowerCase()
|
|
637
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
638
|
+
.replace(/^-|-$/g, "") || "project";
|
|
639
|
+
try {
|
|
640
|
+
const created = await this.call("/v1/repositories", {
|
|
641
|
+
method: "POST",
|
|
642
|
+
contentType: "application/json",
|
|
643
|
+
body: JSON.stringify({
|
|
644
|
+
slug,
|
|
645
|
+
displayName: request.projectName.trim() || slug,
|
|
646
|
+
description: "",
|
|
647
|
+
visibility: "private",
|
|
648
|
+
}),
|
|
649
|
+
});
|
|
650
|
+
if (this.planning)
|
|
651
|
+
this.planningCreatedRepositoryId = created.id;
|
|
652
|
+
return created.id;
|
|
630
653
|
}
|
|
631
|
-
|
|
632
|
-
// Otherwise their first feature questions race each other and a busy
|
|
633
|
-
// service can make one lane cache a false "unsupported" answer.
|
|
634
|
-
await this.serviceCapabilities();
|
|
635
|
-
/*
|
|
636
|
-
Sent in sections, not in one attempt.
|
|
637
|
-
|
|
638
|
-
A section is a bounded amount of work — a few hundred megabytes, or a few
|
|
639
|
-
thousand files, whichever comes first — and each one finishes before the
|
|
640
|
-
next begins. A small project is a single section and behaves exactly as
|
|
641
|
-
it did; a thirty gigabyte one is many, and holds no more at a time than
|
|
642
|
-
the small one does.
|
|
643
|
-
|
|
644
|
-
The sections are planned from the survey rather than discovered as the
|
|
645
|
-
upload goes, so the size and shape of the work are known before a byte
|
|
646
|
-
moves, and a resumed upload plans the same sections in the same order.
|
|
647
|
-
*/
|
|
648
|
-
const allDeclarations = declarations;
|
|
649
|
-
const declarationByPath = new Map(allDeclarations.map((one) => [one.path, one]));
|
|
650
|
-
/*
|
|
651
|
-
The narrow first use of the delta transport.
|
|
652
|
-
|
|
653
|
-
One changed small file used to miss solid packing (which correctly
|
|
654
|
-
requires a group) and upload its complete compressed bytes. When more
|
|
655
|
-
than one small file changes, the existing pack remains the better
|
|
656
|
-
answer and is deliberately untouched. New files have no receiver base
|
|
657
|
-
and also stay on the existing path.
|
|
658
|
-
*/
|
|
659
|
-
const logicallyChanged = allDeclarations.filter((one) => prior.get(one.path)?.sha256 !== one.sha256);
|
|
660
|
-
const deltaDeclaration = logicallyChanged.length === 1 &&
|
|
661
|
-
request.baseVersionId &&
|
|
662
|
-
prior.has(logicallyChanged[0].path) &&
|
|
663
|
-
logicallyChanged[0].size <= BATCH_FILE_LIMIT &&
|
|
664
|
-
logicallyChanged[0].size <= delta_js_1.DELTA_MAX_FILE_BYTES &&
|
|
665
|
-
prior.get(logicallyChanged[0].path).sourceSize <= delta_js_1.DELTA_MAX_FILE_BYTES
|
|
666
|
-
? logicallyChanged[0]
|
|
667
|
-
: null;
|
|
668
|
-
const sections = (0, staging_js_1.planSections)(allDeclarations.map((one) => ({ path: one.path, size: one.size })));
|
|
669
|
-
(0, profile_js_1.counted)("sections planned", sections.length);
|
|
670
|
-
const uploaded = [];
|
|
671
|
-
let sentBytes = 0;
|
|
672
|
-
/*
|
|
673
|
-
Bytes that actually travelled, which is not the same as bytes accounted
|
|
674
|
-
for: a file the service already held advances the progress bar and costs
|
|
675
|
-
nothing. Reporting the rate from the second number makes a fast, mostly
|
|
676
|
-
reused save look like a stalled one.
|
|
677
|
-
*/
|
|
678
|
-
let transferred = 0;
|
|
679
|
-
/*
|
|
680
|
-
Files whose content the service already had. Counted apart from what was
|
|
681
|
-
sent, because reporting them as sent would overstate the work and hide
|
|
682
|
-
the thing worth knowing: an interrupted save costs nothing to finish.
|
|
683
|
-
*/
|
|
684
|
-
let alreadyOnAccount = 0;
|
|
685
|
-
/*
|
|
686
|
-
How far each lane is through the file it currently holds.
|
|
687
|
-
|
|
688
|
-
With one worker, "everything finished plus how far I am" was simply
|
|
689
|
-
true. With six it is not: each lane added its own offset to the same
|
|
690
|
-
shared total, so the reported figure jumped between six different
|
|
691
|
-
answers and the progress bar ran backwards. Keeping the offsets apart
|
|
692
|
-
and summing them makes the total the sum of real work again.
|
|
693
|
-
*/
|
|
694
|
-
const inFlight = new Map();
|
|
695
|
-
const progressBytes = () => {
|
|
696
|
-
let total = sentBytes;
|
|
697
|
-
for (const offset of inFlight.values())
|
|
698
|
-
total += offset;
|
|
699
|
-
return total;
|
|
700
|
-
};
|
|
701
|
-
/*
|
|
702
|
-
How fast the thing on screen is moving.
|
|
703
|
-
|
|
704
|
-
Two faults, and they compounded. The rate was `transferred` over the
|
|
705
|
-
whole elapsed time, while the bar beside it showed `progressBytes()` —
|
|
706
|
-
different counters measuring different things. `transferred` only moves
|
|
707
|
-
when a file *finishes*, so a single large file reported a rate of
|
|
708
|
-
roughly nothing for its entire transfer and then jumped at the end: one
|
|
709
|
-
real upload sat at "152 MB of 1.1 GB · 10 B/s", which is not a slow
|
|
710
|
-
upload, it is a number describing something else.
|
|
711
|
-
|
|
712
|
-
And dividing by the time since the start made it a lifetime average, so
|
|
713
|
-
a slow first minute went on dragging the figure down long after the
|
|
714
|
-
link had recovered — the opposite of what somebody watching it wants to
|
|
715
|
-
know, which is whether it is moving *now*.
|
|
716
|
-
|
|
717
|
-
So: the same counter the bar uses, over a short trailing window.
|
|
718
|
-
*/
|
|
719
|
-
const RATE_WINDOW_MS = 5000;
|
|
720
|
-
const samples = [];
|
|
721
|
-
const rate = () => {
|
|
722
|
-
const now = Date.now();
|
|
723
|
-
const bytes = progressBytes();
|
|
724
|
-
const last = samples[samples.length - 1];
|
|
725
|
-
// Sampled rather than recorded per call: this is asked once per report,
|
|
726
|
-
// and reports arrive per file, which for small files is thousands a
|
|
727
|
-
// second.
|
|
728
|
-
if (!last || now - last.at >= 250)
|
|
729
|
-
samples.push({ at: now, bytes });
|
|
730
|
-
while (samples.length > 2 && now - samples[0].at > RATE_WINDOW_MS) {
|
|
731
|
-
samples.shift();
|
|
732
|
-
}
|
|
733
|
-
const oldest = samples[0];
|
|
734
|
-
const seconds = (now - oldest.at) / 1000;
|
|
654
|
+
catch (error) {
|
|
735
655
|
/*
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
656
|
+
The account already has a project of this name — which happens after a
|
|
657
|
+
reinstall, or when the folder has moved. Saving into it is what was
|
|
658
|
+
meant; failing would strand the folder.
|
|
739
659
|
*/
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
share of bytes that will be batched splits the same game nine and twelve.
|
|
765
|
-
*/
|
|
766
|
-
const smallShare = (one) => one.files
|
|
767
|
-
.filter((file) => file.size <= BATCH_FILE_LIMIT)
|
|
768
|
-
.reduce((sum, file) => sum + file.size, 0) / Math.max(one.bytes, 1);
|
|
769
|
-
const batchy = sections.filter((one) => smallShare(one) >= 0.5);
|
|
770
|
-
const heavy = sections.filter((one) => smallShare(one) < 0.5);
|
|
771
|
-
(0, profile_js_1.counted)("sections batched", batchy.length);
|
|
772
|
-
(0, profile_js_1.counted)("sections heavy", heavy.length);
|
|
773
|
-
/*
|
|
774
|
-
How many files the whole save has finished, across every section.
|
|
775
|
-
|
|
776
|
-
The sections are an implementation detail of not holding thirty
|
|
777
|
-
gigabytes in memory at once, and they were leaking into what a person
|
|
778
|
-
reads: the count ran up to twenty-one thousand while reading, then reset
|
|
779
|
-
to "0 of 4,000" for each section and counted up again, over and over. It
|
|
780
|
-
looked like the save was starting over, repeatedly.
|
|
781
|
-
|
|
782
|
-
One denominator for the whole save. Sections still say which one is in
|
|
783
|
-
flight, in words, where that belongs.
|
|
784
|
-
*/
|
|
785
|
-
const sectionProgress = new Map();
|
|
786
|
-
/*
|
|
787
|
-
What has already been shown, so the number never goes backwards.
|
|
788
|
-
|
|
789
|
-
Two pipelines run sections at once and each reports its own share, which
|
|
790
|
-
I have now twice failed to combine into a figure that only rises — the
|
|
791
|
-
count kept dropping to zero at section boundaries and reading as though
|
|
792
|
-
the save had restarted. Whatever the accounting does, the panel must not
|
|
793
|
-
be told a smaller number than it was told a moment ago: going backwards
|
|
794
|
-
is the specific thing a person notices, and it is never information.
|
|
795
|
-
*/
|
|
796
|
-
let shown = 0;
|
|
797
|
-
const forward = (count) => {
|
|
798
|
-
if (count > shown)
|
|
799
|
-
shown = count;
|
|
800
|
-
return shown;
|
|
801
|
-
};
|
|
802
|
-
const runSection = async (section) => {
|
|
803
|
-
const declarations = section.files.map((file) => declarationByPath.get(file.path));
|
|
660
|
+
const existing = await this.findBySlug(slug);
|
|
661
|
+
if (!existing)
|
|
662
|
+
throw error;
|
|
663
|
+
return existing;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Name the version, which is what makes everything already stored visible.
|
|
668
|
+
*
|
|
669
|
+
* Its own method because the two halves of a save fail differently: up to
|
|
670
|
+
* here nothing on the account has changed and a retry costs only the bytes
|
|
671
|
+
* that did not arrive, while past here there is a version. Keeping the
|
|
672
|
+
* boundary a function call rather than a comment makes that legible.
|
|
673
|
+
*/
|
|
674
|
+
async publishVersion(at) {
|
|
675
|
+
const { request, repositoryId, declarations, uploaded, reused, prior, everything, ticked, deletions, readFailures, vanished, totalBytes, progress, report, } = at;
|
|
676
|
+
// 4. Name the version, which is what makes the upload visible.
|
|
677
|
+
this.check();
|
|
678
|
+
report({
|
|
679
|
+
stage: "publish",
|
|
680
|
+
files: declarations.length,
|
|
681
|
+
totalFiles: declarations.length,
|
|
682
|
+
bytes: progress.sentBytes,
|
|
683
|
+
totalBytes,
|
|
804
684
|
/*
|
|
805
|
-
|
|
685
|
+
A save, not a version.
|
|
806
686
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
687
|
+
Sending produces a commit; it becomes a version when somebody names
|
|
688
|
+
it, which happens later and on purpose. This line said "Recording the
|
|
689
|
+
version" while doing neither — it was the last wording left over from
|
|
690
|
+
before the two were separate, and it sat on the one screen a person
|
|
691
|
+
watches to the end.
|
|
811
692
|
*/
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
const held = sectionProgress.get(section.index) ?? 0;
|
|
823
|
-
if (done > held)
|
|
824
|
-
sectionProgress.set(section.index, done);
|
|
825
|
-
else if (!sectionProgress.has(section.index)) {
|
|
826
|
-
sectionProgress.set(section.index, 0);
|
|
827
|
-
}
|
|
828
|
-
let sum = 0;
|
|
829
|
-
for (const each of sectionProgress.values())
|
|
830
|
-
sum += each;
|
|
831
|
-
return forward(sum);
|
|
832
|
-
};
|
|
833
|
-
report({
|
|
834
|
-
stage: "upload",
|
|
693
|
+
path: "Writing down what was sent",
|
|
694
|
+
percent: 96,
|
|
695
|
+
bytesPerSecond: progress.rate(),
|
|
696
|
+
});
|
|
697
|
+
// The version names everything the project holds: what was just sent,
|
|
698
|
+
// plus every file that kept the object the last version pointed at.
|
|
699
|
+
const contents = [
|
|
700
|
+
...uploaded,
|
|
701
|
+
...reused.map((file) => {
|
|
702
|
+
const held = prior.get(file.path);
|
|
835
703
|
/*
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
done — so every section boundary threw the number back to the start
|
|
840
|
-
and the bar with it. Four of the five reports in this stage were
|
|
841
|
-
changed to carry the running total and this one, the only one a
|
|
842
|
-
person is guaranteed to see, was missed.
|
|
704
|
+
Republished in the shape it was already stored in. A file kept in
|
|
705
|
+
pieces names the same pieces; flattening it to one object here would
|
|
706
|
+
name an object that was never created.
|
|
843
707
|
*/
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
708
|
+
return {
|
|
709
|
+
path: file.path,
|
|
710
|
+
/*
|
|
711
|
+
Republished in the shape it is stored in — all three of them. The
|
|
712
|
+
packed case was missing, so an unchanged small file was declared
|
|
713
|
+
with an empty object id instead of the slice it actually is.
|
|
714
|
+
*/
|
|
715
|
+
...(held.pack
|
|
716
|
+
? {
|
|
717
|
+
/*
|
|
718
|
+
The uploader's own shape for a pack slice, which is what
|
|
719
|
+
the publish step converts to the wire. A file that came
|
|
720
|
+
back from the service is described the same way as one this
|
|
721
|
+
run packed, so the two paths converge before publishing
|
|
722
|
+
rather than at it.
|
|
723
|
+
*/
|
|
724
|
+
pack: {
|
|
725
|
+
packObjectId: held.pack.objectId,
|
|
726
|
+
offset: held.pack.offset,
|
|
727
|
+
length: held.pack.length,
|
|
728
|
+
storedSize: held.storedSize,
|
|
729
|
+
},
|
|
730
|
+
sha256: held.sha256,
|
|
731
|
+
}
|
|
732
|
+
: held.chunks?.length
|
|
733
|
+
? { chunks: held.chunks, sha256: held.sha256 }
|
|
734
|
+
: { objectId: held.objectId }),
|
|
735
|
+
sourceSize: held.sourceSize,
|
|
736
|
+
storedSize: held.storedSize,
|
|
737
|
+
mediaType: held.mediaType,
|
|
738
|
+
};
|
|
739
|
+
}),
|
|
740
|
+
].sort((left, right) => left.path.localeCompare(right.path));
|
|
741
|
+
/*
|
|
742
|
+
Last check before the version is written: everything the person ticked
|
|
743
|
+
has to be in it. Reaching this with something missing would publish a
|
|
744
|
+
snapshot that silently lacked a file, which is the one failure a backup
|
|
745
|
+
tool must never have.
|
|
746
|
+
*/
|
|
747
|
+
const named = new Set(contents.map((item) => item.path));
|
|
748
|
+
const dropped = [
|
|
749
|
+
...new Set([
|
|
750
|
+
...vanished,
|
|
751
|
+
...[...ticked].filter((chosen) => !named.has(chosen) && !deletions.has(chosen)),
|
|
752
|
+
]),
|
|
753
|
+
];
|
|
754
|
+
if (dropped.length) {
|
|
755
|
+
const shown = dropped.slice(0, 5).join(", ");
|
|
756
|
+
const rest = dropped.length > 5 ? ` and ${dropped.length - 5} more` : "";
|
|
757
|
+
const details = dropped
|
|
758
|
+
.map((name) => readFailures.get(name) ? `${name}: ${readFailures.get(name)}` : "")
|
|
759
|
+
.filter(Boolean)
|
|
760
|
+
.slice(0, 3)
|
|
761
|
+
.join("; ");
|
|
762
|
+
/*
|
|
763
|
+
Say what to do about it. Refusing is right — a version quietly missing
|
|
764
|
+
a file somebody chose is the one failure a backup tool must never have
|
|
765
|
+
— but the message stopped at the refusal, and the person is left with
|
|
766
|
+
a list of paths and no idea that the fix is either closing whatever
|
|
767
|
+
holds them or unticking them. Almost every case is a file another
|
|
768
|
+
program has open, so that is what it says.
|
|
769
|
+
*/
|
|
770
|
+
const excluded = dropped.some((name) => readFailures.get(name)?.includes("ignore rules"));
|
|
771
|
+
throw new Error(`${dropped.length} selected file${dropped.length === 1 ? "" : "s"} could not be included, ` +
|
|
772
|
+
`so nothing was saved: ${shown}${rest}. ` +
|
|
773
|
+
(excluded
|
|
774
|
+
? `At least one is excluded by the project's ignore rules. Change the rules or untick it and try again. `
|
|
775
|
+
: `Another program may have a file open. Close it or untick the file and try again. `) +
|
|
776
|
+
`Nothing was changed on your account.` +
|
|
777
|
+
(details ? ` Details: ${details}` : ""));
|
|
778
|
+
}
|
|
779
|
+
const sourceBytes = contents.reduce((total, item) => total + item.sourceSize, 0);
|
|
780
|
+
const storedBytes = contents.reduce((total, item) => total + item.storedSize, 0);
|
|
781
|
+
/*
|
|
782
|
+
Every object is uploaded and verified; nothing on the account points at
|
|
783
|
+
them yet. Stopping here has to leave no version and no partial one — the
|
|
784
|
+
objects are immutable and content-addressed, so a retry reuses them.
|
|
785
|
+
*/
|
|
786
|
+
(0, faults_js_1.maybeFail)("submit:after-objects");
|
|
787
|
+
const completed = await (0, profile_js_1.timed)("publish the version", () => this.call(`/v1/repositories/${repositoryId}/versions`, {
|
|
788
|
+
method: "POST",
|
|
789
|
+
contentType: "application/json",
|
|
790
|
+
body: JSON.stringify({
|
|
791
|
+
message: request.message.trim() || "Saved from the CodeRook desktop app",
|
|
792
|
+
...(request.baseVersionId === undefined
|
|
793
|
+
? {}
|
|
794
|
+
: {
|
|
795
|
+
ancestryMode: "required",
|
|
796
|
+
baseVersionId: request.baseVersionId,
|
|
797
|
+
}),
|
|
798
|
+
...(request.expectedHeadVersionId === undefined
|
|
799
|
+
? {}
|
|
800
|
+
: { expectedHeadVersionId: request.expectedHeadVersionId }),
|
|
801
|
+
...(request.track ? { track: request.track } : {}),
|
|
802
|
+
...(request.allowIgnored ? { allowIgnored: true } : {}),
|
|
803
|
+
...(request.allowSecrets ? { allowSecrets: true } : {}),
|
|
804
|
+
...(request.linesAdded === undefined ? {} : { linesAdded: request.linesAdded }),
|
|
805
|
+
...(request.linesRemoved === undefined
|
|
806
|
+
? {}
|
|
807
|
+
: { linesRemoved: request.linesRemoved }),
|
|
808
|
+
/*
|
|
809
|
+
Names this attempt so a retry after a lost connection is answered
|
|
810
|
+
with the version already made, rather than making a second one.
|
|
811
|
+
*/
|
|
812
|
+
idempotencyKey: (0, publish_name_js_1.publishAttemptName)({
|
|
813
|
+
repositoryId,
|
|
814
|
+
expectedHeadVersionId: request.expectedHeadVersionId,
|
|
815
|
+
message: request.message,
|
|
816
|
+
/*
|
|
817
|
+
Named by what each file is. A whole file is its object; a chunked
|
|
818
|
+
one is the digest of its contents — so retrying the same publish
|
|
819
|
+
produces the same key either way, which is the point of it.
|
|
820
|
+
*/
|
|
821
|
+
files: contents.map((item) => ({
|
|
822
|
+
path: item.path,
|
|
823
|
+
objectId: item.objectId ?? item.sha256 ?? "",
|
|
824
|
+
})),
|
|
825
|
+
}),
|
|
826
|
+
sourceSize: sourceBytes,
|
|
827
|
+
storedSize: storedBytes,
|
|
828
|
+
/*
|
|
829
|
+
One shape or the other, never both — the service refuses a file that
|
|
830
|
+
names an object and a chunk list, and so does its database.
|
|
831
|
+
*/
|
|
832
|
+
files: contents.map((item) => ({
|
|
833
|
+
path: item.path,
|
|
834
|
+
...(item.pack
|
|
835
|
+
? {
|
|
836
|
+
pack: {
|
|
837
|
+
objectId: item.pack.packObjectId,
|
|
838
|
+
offset: item.pack.offset,
|
|
839
|
+
length: item.pack.length,
|
|
840
|
+
},
|
|
841
|
+
sha256: item.sha256,
|
|
842
|
+
}
|
|
843
|
+
: item.chunks
|
|
844
|
+
? { chunks: item.chunks, sha256: item.sha256 }
|
|
845
|
+
: { objectId: item.objectId }),
|
|
846
|
+
sourceSize: item.sourceSize,
|
|
847
|
+
storedSize: item.storedSize,
|
|
848
|
+
mediaType: item.mediaType,
|
|
849
|
+
executable: false,
|
|
850
|
+
})),
|
|
851
|
+
}),
|
|
852
|
+
}));
|
|
853
|
+
/*
|
|
854
|
+
A clean stale-base save may contain a server-side merge. The candidate
|
|
855
|
+
manifest built above is then not the Version that landed: it lacks the
|
|
856
|
+
other publisher's safe changes. Persist the service's accepted snapshot
|
|
857
|
+
instead. A repeated attempt predates manifest echoing, so read that
|
|
858
|
+
Version once rather than guessing.
|
|
859
|
+
*/
|
|
860
|
+
let acceptedManifest = (0, publication_js_1.acceptedManifestDigests)(completed.manifest?.files);
|
|
861
|
+
if (!completed.mergeTrack && !Object.keys(acceptedManifest).length) {
|
|
862
|
+
acceptedManifest = Object.fromEntries([...(await this.versionFiles(repositoryId, completed.version.id))].map(([file, descriptor]) => [file, descriptor.sha256]));
|
|
863
|
+
}
|
|
864
|
+
report({
|
|
865
|
+
stage: "done",
|
|
866
|
+
files: declarations.length,
|
|
867
|
+
totalFiles: declarations.length,
|
|
868
|
+
bytes: progress.sentBytes,
|
|
869
|
+
totalBytes,
|
|
870
|
+
path: `Version ${completed.version.sequence} saved`,
|
|
871
|
+
percent: 100,
|
|
872
|
+
bytesPerSecond: progress.rate(),
|
|
873
|
+
});
|
|
874
|
+
return {
|
|
875
|
+
repositoryId,
|
|
876
|
+
versionId: completed.version.id,
|
|
877
|
+
sequence: completed.version.sequence,
|
|
878
|
+
...(completed.version.state === "held" ? { held: true } : {}),
|
|
879
|
+
...(completed.mergeTrack ? { mergeTrack: completed.mergeTrack } : {}),
|
|
880
|
+
...(completed.repeated ? { repeated: true } : {}),
|
|
881
|
+
sourceBytes: completed.version.sourceSize ?? sourceBytes,
|
|
882
|
+
storedBytes: completed.version.storedSize ?? storedBytes,
|
|
883
|
+
sentBytes: progress.transferred,
|
|
884
|
+
sentFiles: uploaded.length - progress.alreadyOnAccount,
|
|
885
|
+
reusedFiles: reused.length,
|
|
886
|
+
/** Selected, but the service already held the content. */
|
|
887
|
+
alreadyStoredFiles: progress.alreadyOnAccount,
|
|
888
|
+
telemetry: this.telemetry.snapshot(),
|
|
889
|
+
// A file that kept its old object records the digest of *that* copy,
|
|
890
|
+
// not of the file on disk, so an unticked edit is still pending next
|
|
891
|
+
// time rather than looking as though it had been saved.
|
|
892
|
+
/*
|
|
893
|
+
What this folder holds after the save. A file that was sent holds
|
|
894
|
+
the bytes that were sent. Anything else keeps whatever digest was
|
|
895
|
+
recorded before: an unticked edit therefore stays pending rather
|
|
896
|
+
than looking saved, and a copy left stale by somebody else's merge
|
|
897
|
+
stays recognisably stale rather than looking like a new edit.
|
|
898
|
+
*/
|
|
899
|
+
local: Object.fromEntries(everything.flatMap((file) => {
|
|
900
|
+
const sent = declarations.find((one) => one.path === file.path);
|
|
901
|
+
if (sent)
|
|
902
|
+
return [[file.path, sent.sha256]];
|
|
903
|
+
const before = request.known?.[file.path] ?? prior.get(file.path)?.sha256;
|
|
904
|
+
return before ? [[file.path, before]] : [];
|
|
905
|
+
})),
|
|
906
|
+
manifest: Object.keys(acceptedManifest).length
|
|
907
|
+
? acceptedManifest
|
|
908
|
+
: {
|
|
909
|
+
...Object.fromEntries(reused.map((file) => [file.path, prior.get(file.path).sha256])),
|
|
910
|
+
...Object.fromEntries(declarations.map((declaration) => [declaration.path, declaration.sha256])),
|
|
911
|
+
},
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Put every object on the account, and record how each one was stored.
|
|
916
|
+
*
|
|
917
|
+
* The longest stage, and where a save spends its time. Kept whole rather
|
|
918
|
+
* than split further because its parts are not independent: the delta path,
|
|
919
|
+
* the solid packs, the batches and the per-file lanes are four ways of
|
|
920
|
+
* answering one question, and which one a file takes is decided by the
|
|
921
|
+
* section it landed in.
|
|
922
|
+
*
|
|
923
|
+
* Nothing here changes anything visible on the account. Objects are
|
|
924
|
+
* immutable and content-addressed, so an interrupted send costs only the
|
|
925
|
+
* bytes that did not arrive — which is why the version is named afterwards
|
|
926
|
+
* rather than before.
|
|
927
|
+
*/
|
|
928
|
+
async sendObjects(at) {
|
|
929
|
+
const { request, repositoryId, prior,
|
|
930
|
+
/*
|
|
931
|
+
Bound under a distinct name on purpose. `runSection` declares its own
|
|
932
|
+
`declarations` for the section it is working on, and a progress report
|
|
933
|
+
reading the shadowed one says "file 3 of 40" when the save holds
|
|
934
|
+
twenty thousand.
|
|
935
|
+
*/
|
|
936
|
+
declarations: everyDeclaration, declarationByPath, deltaDeclaration, sections, totalBytes, progress, uploaded, report, } = at;
|
|
937
|
+
const smallShare = (one) => one.files
|
|
938
|
+
.filter((file) => file.size <= BATCH_FILE_LIMIT)
|
|
939
|
+
.reduce((sum, file) => sum + file.size, 0) / Math.max(one.bytes, 1);
|
|
940
|
+
const batchy = sections.filter((one) => smallShare(one) >= 0.5);
|
|
941
|
+
const heavy = sections.filter((one) => smallShare(one) < 0.5);
|
|
942
|
+
(0, profile_js_1.counted)("sections batched", batchy.length);
|
|
943
|
+
(0, profile_js_1.counted)("sections heavy", heavy.length);
|
|
944
|
+
/*
|
|
945
|
+
How many files the whole save has finished, across every section.
|
|
946
|
+
|
|
947
|
+
The sections are an implementation detail of not holding thirty
|
|
948
|
+
gigabytes in memory at once, and they were leaking into what a person
|
|
949
|
+
reads: the count ran up to twenty-one thousand while reading, then reset
|
|
950
|
+
to "0 of 4,000" for each section and counted up again, over and over. It
|
|
951
|
+
looked like the save was starting over, repeatedly.
|
|
952
|
+
|
|
953
|
+
One denominator for the whole save. Sections still say which one is in
|
|
954
|
+
flight, in words, where that belongs.
|
|
955
|
+
*/
|
|
956
|
+
const sectionProgress = new Map();
|
|
957
|
+
/*
|
|
958
|
+
What has already been shown, so the number never goes backwards.
|
|
959
|
+
|
|
960
|
+
Two pipelines run sections at once and each reports its own share, which
|
|
961
|
+
I have now twice failed to combine into a figure that only rises — the
|
|
962
|
+
count kept dropping to zero at section boundaries and reading as though
|
|
963
|
+
the save had restarted. Whatever the accounting does, the panel must not
|
|
964
|
+
be told a smaller number than it was told a moment ago: going backwards
|
|
965
|
+
is the specific thing a person notices, and it is never information.
|
|
966
|
+
*/
|
|
967
|
+
let shown = 0;
|
|
968
|
+
const forward = (count) => {
|
|
969
|
+
if (count > shown)
|
|
970
|
+
shown = count;
|
|
971
|
+
return shown;
|
|
972
|
+
};
|
|
973
|
+
const runSection = async (section) => {
|
|
974
|
+
const declarations = section.files.map((file) => declarationByPath.get(file.path));
|
|
975
|
+
/*
|
|
976
|
+
This section's share of the one number the panel shows.
|
|
977
|
+
|
|
978
|
+
Two pipelines run at once, so no section can report on its own behalf
|
|
979
|
+
without the count lurching between them. Each records how far it has
|
|
980
|
+
got and the sum is what is shown, which is the only figure that is
|
|
981
|
+
true while both are moving.
|
|
982
|
+
*/
|
|
983
|
+
const advance = (done) => {
|
|
984
|
+
/*
|
|
985
|
+
Only ever upward.
|
|
986
|
+
|
|
987
|
+
The workers share one iterator, so the indices arrive out of order —
|
|
988
|
+
a worker holding file 5 reports after one holding file 200. Writing
|
|
989
|
+
each straight in let a later, lower number undo a higher one, and
|
|
990
|
+
the count sat still or fell back to zero at every section boundary,
|
|
991
|
+
which is the exact stutter this was meant to remove.
|
|
992
|
+
*/
|
|
993
|
+
const held = sectionProgress.get(section.index) ?? 0;
|
|
994
|
+
if (done > held)
|
|
995
|
+
sectionProgress.set(section.index, done);
|
|
996
|
+
else if (!sectionProgress.has(section.index)) {
|
|
997
|
+
sectionProgress.set(section.index, 0);
|
|
998
|
+
}
|
|
999
|
+
let sum = 0;
|
|
1000
|
+
for (const each of sectionProgress.values())
|
|
1001
|
+
sum += each;
|
|
1002
|
+
return forward(sum);
|
|
1003
|
+
};
|
|
1004
|
+
report({
|
|
1005
|
+
stage: "upload",
|
|
1006
|
+
/*
|
|
1007
|
+
The count so far, not zero.
|
|
1008
|
+
|
|
1009
|
+
This line announces a new section, and it was reporting nought files
|
|
1010
|
+
done — so every section boundary threw the number back to the start
|
|
1011
|
+
and the bar with it. Four of the five reports in this stage were
|
|
1012
|
+
changed to carry the running total and this one, the only one a
|
|
1013
|
+
person is guaranteed to see, was missed.
|
|
1014
|
+
*/
|
|
1015
|
+
files: advance(0),
|
|
1016
|
+
totalFiles: everyDeclaration.length,
|
|
1017
|
+
bytes: progress.bytes(),
|
|
1018
|
+
totalBytes,
|
|
1019
|
+
path: `Section ${section.index + 1} of ${sections.length}`,
|
|
1020
|
+
percent: 20 + Math.round((progress.bytes() / Math.max(totalBytes, 1)) * 72),
|
|
1021
|
+
bytesPerSecond: progress.rate(),
|
|
1022
|
+
});
|
|
1023
|
+
/*
|
|
1024
|
+
Ask once what the account already holds.
|
|
1025
|
+
|
|
1026
|
+
This used to be one request per file, immediately before deciding
|
|
1027
|
+
whether to send it — nine thousand files meant nine thousand serial
|
|
1028
|
+
round trips before a single byte moved, and on a folder of that size the
|
|
1029
|
+
asking took longer than the transfer. The answer per digest is a boolean
|
|
1030
|
+
and an identifier, so the whole question fits in one request.
|
|
1031
|
+
*/
|
|
1032
|
+
const alreadyHeld = await (0, profile_js_1.timed)("ask what is already held", () => this.storedInBulk(repositoryId, declarations.map((one) => one.sha256)));
|
|
1033
|
+
/*
|
|
1034
|
+
The small files go first, packed together.
|
|
1035
|
+
|
|
1036
|
+
They are the overwhelming majority by count and almost nothing by size,
|
|
1037
|
+
so sending them one per request is where an upload spends its time. What
|
|
1038
|
+
is left after this — the large files — is where bandwidth actually
|
|
1039
|
+
matters, and those still go one per request through the lanes below.
|
|
1040
|
+
*/
|
|
1041
|
+
const missing = declarations.filter((one) => !alreadyHeld.has(one.sha256));
|
|
1042
|
+
const smallOnes = missing.filter((one) => one.size <= BATCH_FILE_LIMIT);
|
|
1043
|
+
// 3. Send the objects.
|
|
1044
|
+
/*
|
|
1045
|
+
What was sent, in whichever shape it was sent. A small file is one
|
|
1046
|
+
object; a large one is an ordered list of chunks and the digest of the
|
|
1047
|
+
whole, which is what gives it an identity when no single object holds it.
|
|
1048
|
+
*/
|
|
1049
|
+
const batched = new Map();
|
|
1050
|
+
/*
|
|
1051
|
+
Files that went up inside a solid pack. Kept apart from `batched` because
|
|
1052
|
+
they are named differently in the version: a slice of a pack rather than
|
|
1053
|
+
an object of their own.
|
|
1054
|
+
*/
|
|
1055
|
+
const packedInto = new Map();
|
|
885
1056
|
if (deltaDeclaration &&
|
|
886
1057
|
smallOnes.some((one) => one.path === deltaDeclaration.path)) {
|
|
887
1058
|
const held = prior.get(deltaDeclaration.path);
|
|
@@ -891,8 +1062,7 @@ class Uploader {
|
|
|
891
1062
|
objectId: placed.objectId,
|
|
892
1063
|
size: placed.storedSize,
|
|
893
1064
|
});
|
|
894
|
-
|
|
895
|
-
transferred += placed.sentBytes;
|
|
1065
|
+
progress.finished(deltaDeclaration.size, placed.sentBytes);
|
|
896
1066
|
(0, profile_js_1.counted)("files sent as deltas", 1);
|
|
897
1067
|
}
|
|
898
1068
|
}
|
|
@@ -999,21 +1169,19 @@ class Uploader {
|
|
|
999
1169
|
const digest = item.declaration.sha256;
|
|
1000
1170
|
if (!batched.has(digest) && !packedInto.has(digest))
|
|
1001
1171
|
continue;
|
|
1002
|
-
|
|
1003
|
-
if (exactWireBytes === null)
|
|
1004
|
-
transferred += item.encoded.body.byteLength;
|
|
1172
|
+
progress.finished(item.declaration.size, exactWireBytes === null ? item.encoded.body.byteLength : 0);
|
|
1005
1173
|
}
|
|
1006
1174
|
if (exactWireBytes !== null)
|
|
1007
|
-
|
|
1175
|
+
progress.finished(0, exactWireBytes);
|
|
1008
1176
|
report({
|
|
1009
1177
|
stage: "upload",
|
|
1010
1178
|
files: advance(0),
|
|
1011
|
-
totalFiles:
|
|
1012
|
-
bytes:
|
|
1179
|
+
totalFiles: everyDeclaration.length,
|
|
1180
|
+
bytes: progress.bytes(),
|
|
1013
1181
|
totalBytes,
|
|
1014
1182
|
path: taking[taking.length - 1].declaration.path,
|
|
1015
|
-
percent: 20 + Math.round((
|
|
1016
|
-
bytesPerSecond: rate(),
|
|
1183
|
+
percent: 20 + Math.round((progress.bytes() / Math.max(totalBytes, 1)) * 72),
|
|
1184
|
+
bytesPerSecond: progress.rate(),
|
|
1017
1185
|
});
|
|
1018
1186
|
})().finally(() => flying.delete(task));
|
|
1019
1187
|
flying.add(task);
|
|
@@ -1061,12 +1229,12 @@ class Uploader {
|
|
|
1061
1229
|
report({
|
|
1062
1230
|
stage: "upload",
|
|
1063
1231
|
files: advance(index),
|
|
1064
|
-
totalFiles:
|
|
1065
|
-
bytes:
|
|
1232
|
+
totalFiles: everyDeclaration.length,
|
|
1233
|
+
bytes: progress.bytes(),
|
|
1066
1234
|
totalBytes,
|
|
1067
1235
|
path: declaration.path,
|
|
1068
|
-
percent: 20 + Math.round((
|
|
1069
|
-
bytesPerSecond: rate(),
|
|
1236
|
+
percent: 20 + Math.round((progress.bytes() / Math.max(totalBytes, 1)) * 72),
|
|
1237
|
+
bytesPerSecond: progress.rate(),
|
|
1070
1238
|
});
|
|
1071
1239
|
/*
|
|
1072
1240
|
Content the service already holds does not need sending. Asking costs
|
|
@@ -1092,20 +1260,20 @@ class Uploader {
|
|
|
1092
1260
|
declaration.size >= CHUNK_THRESHOLD &&
|
|
1093
1261
|
(await this.chunkingAllowed())) {
|
|
1094
1262
|
const pieces = await this.putChunked(repositoryId, declaration, chunkProfile, (offset) => {
|
|
1095
|
-
|
|
1263
|
+
progress.lane(index, offset);
|
|
1096
1264
|
report({
|
|
1097
1265
|
stage: "upload",
|
|
1098
1266
|
files: advance(index),
|
|
1099
|
-
totalFiles:
|
|
1100
|
-
bytes:
|
|
1267
|
+
totalFiles: everyDeclaration.length,
|
|
1268
|
+
bytes: progress.bytes(),
|
|
1101
1269
|
totalBytes,
|
|
1102
1270
|
path: declaration.path,
|
|
1103
1271
|
percent: 20 +
|
|
1104
|
-
Math.round(((
|
|
1105
|
-
bytesPerSecond: rate(),
|
|
1272
|
+
Math.round((progress.bytes() / Math.max(totalBytes, 1)) * 72),
|
|
1273
|
+
bytesPerSecond: progress.rate(),
|
|
1106
1274
|
});
|
|
1107
1275
|
});
|
|
1108
|
-
|
|
1276
|
+
progress.laneDone(index);
|
|
1109
1277
|
/*
|
|
1110
1278
|
A chunk is not a file. Counting every reused piece here made a
|
|
1111
1279
|
one-file save report a negative number of sent files. The selected
|
|
@@ -1113,7 +1281,7 @@ class Uploader {
|
|
|
1113
1281
|
reused and no payload crossed the wire.
|
|
1114
1282
|
*/
|
|
1115
1283
|
if (pieces.sentBytes === 0)
|
|
1116
|
-
|
|
1284
|
+
progress.alreadyHeld();
|
|
1117
1285
|
uploaded.push({
|
|
1118
1286
|
path: declaration.path,
|
|
1119
1287
|
sha256: declaration.sha256,
|
|
@@ -1122,8 +1290,7 @@ class Uploader {
|
|
|
1122
1290
|
storedSize: pieces.chunks.reduce((sum, one) => sum + one.storedSize, 0),
|
|
1123
1291
|
mediaType: declaration.mediaType,
|
|
1124
1292
|
});
|
|
1125
|
-
|
|
1126
|
-
transferred += pieces.sentBytes;
|
|
1293
|
+
progress.finished(declaration.size, pieces.sentBytes);
|
|
1127
1294
|
continue;
|
|
1128
1295
|
}
|
|
1129
1296
|
const fromPack = packedInto.get(declaration.sha256);
|
|
@@ -1158,7 +1325,7 @@ class Uploader {
|
|
|
1158
1325
|
*/
|
|
1159
1326
|
(await this.findStored(repositoryId, declaration));
|
|
1160
1327
|
if (alreadyStored)
|
|
1161
|
-
|
|
1328
|
+
progress.alreadyHeld();
|
|
1162
1329
|
const result = alreadyStored ??
|
|
1163
1330
|
(declaration.size <= DIRECT_LIMIT
|
|
1164
1331
|
? await (0, profile_js_1.timed)("send files one at a time", () => this.putDirect(repositoryId, declaration)).catch((error) => {
|
|
@@ -1167,19 +1334,26 @@ class Uploader {
|
|
|
1167
1334
|
throw new Error(`${declaration.path}: ${reason}`);
|
|
1168
1335
|
})
|
|
1169
1336
|
: await this.putMultipart(repositoryId, declaration, (offset) => {
|
|
1337
|
+
/*
|
|
1338
|
+
Recorded before the report reads it. Without this the bar does
|
|
1339
|
+
not move at all during a multipart file: `bytes()` sums the
|
|
1340
|
+
settled total and the open lanes, and this lane's offset is
|
|
1341
|
+
only in the second of those once it has been set.
|
|
1342
|
+
*/
|
|
1343
|
+
progress.lane(index, offset);
|
|
1170
1344
|
report({
|
|
1171
1345
|
stage: "upload",
|
|
1172
1346
|
files: advance(index),
|
|
1173
|
-
totalFiles:
|
|
1174
|
-
bytes:
|
|
1347
|
+
totalFiles: everyDeclaration.length,
|
|
1348
|
+
bytes: progress.bytes(),
|
|
1175
1349
|
totalBytes,
|
|
1176
1350
|
path: declaration.path,
|
|
1177
1351
|
percent: 20 +
|
|
1178
|
-
Math.round(((
|
|
1179
|
-
bytesPerSecond: rate(),
|
|
1352
|
+
Math.round((progress.bytes() / Math.max(totalBytes, 1)) * 72),
|
|
1353
|
+
bytesPerSecond: progress.rate(),
|
|
1180
1354
|
});
|
|
1181
1355
|
}));
|
|
1182
|
-
|
|
1356
|
+
progress.laneDone(index);
|
|
1183
1357
|
uploaded.push({
|
|
1184
1358
|
path: declaration.path,
|
|
1185
1359
|
objectId: result.objectId,
|
|
@@ -1187,9 +1361,7 @@ class Uploader {
|
|
|
1187
1361
|
storedSize: result.size,
|
|
1188
1362
|
mediaType: declaration.mediaType,
|
|
1189
1363
|
});
|
|
1190
|
-
|
|
1191
|
-
if (!alreadyStored)
|
|
1192
|
-
transferred += declaration.size;
|
|
1364
|
+
progress.finished(declaration.size, alreadyStored ? 0 : declaration.size);
|
|
1193
1365
|
}
|
|
1194
1366
|
};
|
|
1195
1367
|
await Promise.all(Array.from({ length: Math.min(UPLOAD_LANES, declarations.length) }, () => runWorker()));
|
|
@@ -1203,12 +1375,119 @@ class Uploader {
|
|
|
1203
1375
|
}
|
|
1204
1376
|
};
|
|
1205
1377
|
await Promise.all([pipeline(batchy), pipeline(heavy)]);
|
|
1378
|
+
}
|
|
1379
|
+
async runPass(request, report) {
|
|
1380
|
+
this.telemetry = new telemetry_js_1.RepositoryTelemetry();
|
|
1381
|
+
// A version is a snapshot, not a delta, so it has to name every file in
|
|
1382
|
+
// the project — not merely the ones being sent this time. Anything
|
|
1383
|
+
// unchanged keeps the object the previous version already pointed at.
|
|
1384
|
+
/*
|
|
1385
|
+
An import filters nothing. The rules keep local mess out of a working
|
|
1386
|
+
folder, but an imported tree is exactly what the source repository
|
|
1387
|
+
tracked — there is no mess in it, and anything the source tracked in
|
|
1388
|
+
spite of its own rules (which git does, for files committed before the
|
|
1389
|
+
rule) would otherwise be dropped without being mentioned.
|
|
1390
|
+
*/
|
|
1391
|
+
/*
|
|
1392
|
+
Chosen first, and separately: which files this save is about, what the
|
|
1393
|
+
project held before it, and what a ticked path that is no longer on disk
|
|
1394
|
+
means. Nothing below changes any of it.
|
|
1395
|
+
*/
|
|
1396
|
+
const { everything, ticked, prior, sending, reused, deletions, readFailures, vanished, } = await this.selectFiles(request, report);
|
|
1397
|
+
/*
|
|
1398
|
+
Read once, here. Everything below works from the declarations rather
|
|
1399
|
+
than from the disk.
|
|
1400
|
+
*/
|
|
1401
|
+
const { declarations, totalBytes } = await this.hashSelection(request, sending, deletions, readFailures, vanished, report);
|
|
1402
|
+
// 2. The project needs somewhere on the account to live.
|
|
1403
|
+
const repositoryId = await this.ensureRepository(request);
|
|
1404
|
+
// Learn the wire contract once, before the two section pipelines begin.
|
|
1405
|
+
// Otherwise their first feature questions race each other and a busy
|
|
1406
|
+
// service can make one lane cache a false "unsupported" answer.
|
|
1407
|
+
await this.serviceCapabilities();
|
|
1408
|
+
/*
|
|
1409
|
+
Sent in sections, not in one attempt.
|
|
1410
|
+
|
|
1411
|
+
A section is a bounded amount of work — a few hundred megabytes, or a few
|
|
1412
|
+
thousand files, whichever comes first — and each one finishes before the
|
|
1413
|
+
next begins. A small project is a single section and behaves exactly as
|
|
1414
|
+
it did; a thirty gigabyte one is many, and holds no more at a time than
|
|
1415
|
+
the small one does.
|
|
1416
|
+
|
|
1417
|
+
The sections are planned from the survey rather than discovered as the
|
|
1418
|
+
upload goes, so the size and shape of the work are known before a byte
|
|
1419
|
+
moves, and a resumed upload plans the same sections in the same order.
|
|
1420
|
+
*/
|
|
1421
|
+
const declarationByPath = new Map(declarations.map((one) => [one.path, one]));
|
|
1422
|
+
/*
|
|
1423
|
+
The narrow first use of the delta transport.
|
|
1424
|
+
|
|
1425
|
+
One changed small file used to miss solid packing (which correctly
|
|
1426
|
+
requires a group) and upload its complete compressed bytes. When more
|
|
1427
|
+
than one small file changes, the existing pack remains the better
|
|
1428
|
+
answer and is deliberately untouched. New files have no receiver base
|
|
1429
|
+
and also stay on the existing path.
|
|
1430
|
+
*/
|
|
1431
|
+
const logicallyChanged = declarations.filter((one) => prior.get(one.path)?.sha256 !== one.sha256);
|
|
1432
|
+
const deltaDeclaration = logicallyChanged.length === 1 &&
|
|
1433
|
+
request.baseVersionId &&
|
|
1434
|
+
prior.has(logicallyChanged[0].path) &&
|
|
1435
|
+
logicallyChanged[0].size <= BATCH_FILE_LIMIT &&
|
|
1436
|
+
logicallyChanged[0].size <= delta_js_1.DELTA_MAX_FILE_BYTES &&
|
|
1437
|
+
prior.get(logicallyChanged[0].path).sourceSize <= delta_js_1.DELTA_MAX_FILE_BYTES
|
|
1438
|
+
? logicallyChanged[0]
|
|
1439
|
+
: null;
|
|
1440
|
+
const sections = (0, staging_js_1.planSections)(declarations.map((one) => ({ path: one.path, size: one.size })));
|
|
1441
|
+
(0, profile_js_1.counted)("sections planned", sections.length);
|
|
1442
|
+
const uploaded = [];
|
|
1443
|
+
/*
|
|
1444
|
+
Every number a person watches during a save comes from here. It used to
|
|
1445
|
+
be four loose counters and two closures in the middle of this function,
|
|
1446
|
+
which is where the backwards bar and the "10 B/s" reading both came from
|
|
1447
|
+
— see the note on TransferProgress.
|
|
1448
|
+
*/
|
|
1449
|
+
const progress = new transfer_progress_js_1.TransferProgress();
|
|
1450
|
+
/*
|
|
1451
|
+
Two pipelines, not one queue.
|
|
1452
|
+
|
|
1453
|
+
The sections were run one after another, and they do not compete for the
|
|
1454
|
+
same thing: a section of small files spends about three seconds per batch
|
|
1455
|
+
waiting on the service — measured, and independent of how many objects
|
|
1456
|
+
the batch holds — while a section of large files is limited by the link.
|
|
1457
|
+
One leaves bandwidth idle; the other cannot use any more.
|
|
1458
|
+
|
|
1459
|
+
So they run together. Each pipeline takes the next section of its own
|
|
1460
|
+
kind, and when one runs out it stops rather than stealing from the other,
|
|
1461
|
+
because that would put two bandwidth-bound pipelines against a link that
|
|
1462
|
+
is already saturated by one.
|
|
1463
|
+
*/
|
|
1464
|
+
/*
|
|
1465
|
+
Classified by where the bytes are, not by whether every file qualifies.
|
|
1466
|
+
|
|
1467
|
+
Requiring every file to be small put one large file in charge of a whole
|
|
1468
|
+
section: on a four gigabyte game it left three sections in one pipeline
|
|
1469
|
+
and eighteen in the other, which is not two pipelines. Judging by the
|
|
1470
|
+
share of bytes that will be batched splits the same game nine and twelve.
|
|
1471
|
+
*/
|
|
1472
|
+
await this.sendObjects({
|
|
1473
|
+
request,
|
|
1474
|
+
repositoryId,
|
|
1475
|
+
prior,
|
|
1476
|
+
declarations,
|
|
1477
|
+
declarationByPath,
|
|
1478
|
+
deltaDeclaration,
|
|
1479
|
+
sections,
|
|
1480
|
+
totalBytes,
|
|
1481
|
+
progress,
|
|
1482
|
+
uploaded,
|
|
1483
|
+
report,
|
|
1484
|
+
});
|
|
1206
1485
|
if (this.planning) {
|
|
1207
1486
|
report({
|
|
1208
1487
|
stage: "publish",
|
|
1209
1488
|
files: declarations.length,
|
|
1210
1489
|
totalFiles: declarations.length,
|
|
1211
|
-
bytes: sentBytes,
|
|
1490
|
+
bytes: progress.sentBytes,
|
|
1212
1491
|
totalBytes,
|
|
1213
1492
|
path: "Checking storage and monthly allowance",
|
|
1214
1493
|
percent: 96,
|
|
@@ -1238,243 +1517,22 @@ class Uploader {
|
|
|
1238
1517
|
telemetry: this.telemetry.snapshot(),
|
|
1239
1518
|
};
|
|
1240
1519
|
}
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
it, which happens later and on purpose. This line said "Recording the
|
|
1254
|
-
version" while doing neither — it was the last wording left over from
|
|
1255
|
-
before the two were separate, and it sat on the one screen a person
|
|
1256
|
-
watches to the end.
|
|
1257
|
-
*/
|
|
1258
|
-
path: "Writing down what was sent",
|
|
1259
|
-
percent: 96,
|
|
1260
|
-
bytesPerSecond: rate(),
|
|
1261
|
-
});
|
|
1262
|
-
// The version names everything the project holds: what was just sent,
|
|
1263
|
-
// plus every file that kept the object the last version pointed at.
|
|
1264
|
-
const contents = [
|
|
1265
|
-
...uploaded,
|
|
1266
|
-
...reused.map((file) => {
|
|
1267
|
-
const held = prior.get(file.path);
|
|
1268
|
-
/*
|
|
1269
|
-
Republished in the shape it was already stored in. A file kept in
|
|
1270
|
-
pieces names the same pieces; flattening it to one object here would
|
|
1271
|
-
name an object that was never created.
|
|
1272
|
-
*/
|
|
1273
|
-
return {
|
|
1274
|
-
path: file.path,
|
|
1275
|
-
/*
|
|
1276
|
-
Republished in the shape it is stored in — all three of them. The
|
|
1277
|
-
packed case was missing, so an unchanged small file was declared
|
|
1278
|
-
with an empty object id instead of the slice it actually is.
|
|
1279
|
-
*/
|
|
1280
|
-
...(held.pack
|
|
1281
|
-
? {
|
|
1282
|
-
/*
|
|
1283
|
-
The uploader's own shape for a pack slice, which is what
|
|
1284
|
-
the publish step converts to the wire. A file that came
|
|
1285
|
-
back from the service is described the same way as one this
|
|
1286
|
-
run packed, so the two paths converge before publishing
|
|
1287
|
-
rather than at it.
|
|
1288
|
-
*/
|
|
1289
|
-
pack: {
|
|
1290
|
-
packObjectId: held.pack.objectId,
|
|
1291
|
-
offset: held.pack.offset,
|
|
1292
|
-
length: held.pack.length,
|
|
1293
|
-
storedSize: held.storedSize,
|
|
1294
|
-
},
|
|
1295
|
-
sha256: held.sha256,
|
|
1296
|
-
}
|
|
1297
|
-
: held.chunks?.length
|
|
1298
|
-
? { chunks: held.chunks, sha256: held.sha256 }
|
|
1299
|
-
: { objectId: held.objectId }),
|
|
1300
|
-
sourceSize: held.sourceSize,
|
|
1301
|
-
storedSize: held.storedSize,
|
|
1302
|
-
mediaType: held.mediaType,
|
|
1303
|
-
};
|
|
1304
|
-
}),
|
|
1305
|
-
].sort((left, right) => left.path.localeCompare(right.path));
|
|
1306
|
-
/*
|
|
1307
|
-
Last check before the version is written: everything the person ticked
|
|
1308
|
-
has to be in it. Reaching this with something missing would publish a
|
|
1309
|
-
snapshot that silently lacked a file, which is the one failure a backup
|
|
1310
|
-
tool must never have.
|
|
1311
|
-
*/
|
|
1312
|
-
const named = new Set(contents.map((item) => item.path));
|
|
1313
|
-
const dropped = [
|
|
1314
|
-
...new Set([
|
|
1315
|
-
...vanished,
|
|
1316
|
-
...[...ticked].filter((chosen) => !named.has(chosen) && !deletions.has(chosen)),
|
|
1317
|
-
]),
|
|
1318
|
-
];
|
|
1319
|
-
if (dropped.length) {
|
|
1320
|
-
const shown = dropped.slice(0, 5).join(", ");
|
|
1321
|
-
const rest = dropped.length > 5 ? ` and ${dropped.length - 5} more` : "";
|
|
1322
|
-
const details = dropped
|
|
1323
|
-
.map((name) => readFailures.get(name) ? `${name}: ${readFailures.get(name)}` : "")
|
|
1324
|
-
.filter(Boolean)
|
|
1325
|
-
.slice(0, 3)
|
|
1326
|
-
.join("; ");
|
|
1327
|
-
/*
|
|
1328
|
-
Say what to do about it. Refusing is right — a version quietly missing
|
|
1329
|
-
a file somebody chose is the one failure a backup tool must never have
|
|
1330
|
-
— but the message stopped at the refusal, and the person is left with
|
|
1331
|
-
a list of paths and no idea that the fix is either closing whatever
|
|
1332
|
-
holds them or unticking them. Almost every case is a file another
|
|
1333
|
-
program has open, so that is what it says.
|
|
1334
|
-
*/
|
|
1335
|
-
const excluded = dropped.some((name) => readFailures.get(name)?.includes("ignore rules"));
|
|
1336
|
-
throw new Error(`${dropped.length} selected file${dropped.length === 1 ? "" : "s"} could not be included, ` +
|
|
1337
|
-
`so nothing was saved: ${shown}${rest}. ` +
|
|
1338
|
-
(excluded
|
|
1339
|
-
? `At least one is excluded by the project's ignore rules. Change the rules or untick it and try again. `
|
|
1340
|
-
: `Another program may have a file open. Close it or untick the file and try again. `) +
|
|
1341
|
-
`Nothing was changed on your account.` +
|
|
1342
|
-
(details ? ` Details: ${details}` : ""));
|
|
1343
|
-
}
|
|
1344
|
-
const sourceBytes = contents.reduce((total, item) => total + item.sourceSize, 0);
|
|
1345
|
-
const storedBytes = contents.reduce((total, item) => total + item.storedSize, 0);
|
|
1346
|
-
/*
|
|
1347
|
-
Every object is uploaded and verified; nothing on the account points at
|
|
1348
|
-
them yet. Stopping here has to leave no version and no partial one — the
|
|
1349
|
-
objects are immutable and content-addressed, so a retry reuses them.
|
|
1350
|
-
*/
|
|
1351
|
-
(0, faults_js_1.maybeFail)("submit:after-objects");
|
|
1352
|
-
const completed = await (0, profile_js_1.timed)("publish the version", () => this.call(`/v1/repositories/${repositoryId}/versions`, {
|
|
1353
|
-
method: "POST",
|
|
1354
|
-
contentType: "application/json",
|
|
1355
|
-
body: JSON.stringify({
|
|
1356
|
-
message: request.message.trim() || "Saved from the CodeRook desktop app",
|
|
1357
|
-
...(request.baseVersionId === undefined
|
|
1358
|
-
? {}
|
|
1359
|
-
: {
|
|
1360
|
-
ancestryMode: "required",
|
|
1361
|
-
baseVersionId: request.baseVersionId,
|
|
1362
|
-
}),
|
|
1363
|
-
...(request.expectedHeadVersionId === undefined
|
|
1364
|
-
? {}
|
|
1365
|
-
: { expectedHeadVersionId: request.expectedHeadVersionId }),
|
|
1366
|
-
...(request.track ? { track: request.track } : {}),
|
|
1367
|
-
...(request.allowIgnored ? { allowIgnored: true } : {}),
|
|
1368
|
-
...(request.allowSecrets ? { allowSecrets: true } : {}),
|
|
1369
|
-
...(request.linesAdded === undefined ? {} : { linesAdded: request.linesAdded }),
|
|
1370
|
-
...(request.linesRemoved === undefined
|
|
1371
|
-
? {}
|
|
1372
|
-
: { linesRemoved: request.linesRemoved }),
|
|
1373
|
-
/*
|
|
1374
|
-
Names this attempt so a retry after a lost connection is answered
|
|
1375
|
-
with the version already made, rather than making a second one.
|
|
1376
|
-
*/
|
|
1377
|
-
idempotencyKey: (0, publish_name_js_1.publishAttemptName)({
|
|
1378
|
-
repositoryId,
|
|
1379
|
-
expectedHeadVersionId: request.expectedHeadVersionId,
|
|
1380
|
-
message: request.message,
|
|
1381
|
-
/*
|
|
1382
|
-
Named by what each file is. A whole file is its object; a chunked
|
|
1383
|
-
one is the digest of its contents — so retrying the same publish
|
|
1384
|
-
produces the same key either way, which is the point of it.
|
|
1385
|
-
*/
|
|
1386
|
-
files: contents.map((item) => ({
|
|
1387
|
-
path: item.path,
|
|
1388
|
-
objectId: item.objectId ?? item.sha256 ?? "",
|
|
1389
|
-
})),
|
|
1390
|
-
}),
|
|
1391
|
-
sourceSize: sourceBytes,
|
|
1392
|
-
storedSize: storedBytes,
|
|
1393
|
-
/*
|
|
1394
|
-
One shape or the other, never both — the service refuses a file that
|
|
1395
|
-
names an object and a chunk list, and so does its database.
|
|
1396
|
-
*/
|
|
1397
|
-
files: contents.map((item) => ({
|
|
1398
|
-
path: item.path,
|
|
1399
|
-
...(item.pack
|
|
1400
|
-
? {
|
|
1401
|
-
pack: {
|
|
1402
|
-
objectId: item.pack.packObjectId,
|
|
1403
|
-
offset: item.pack.offset,
|
|
1404
|
-
length: item.pack.length,
|
|
1405
|
-
},
|
|
1406
|
-
sha256: item.sha256,
|
|
1407
|
-
}
|
|
1408
|
-
: item.chunks
|
|
1409
|
-
? { chunks: item.chunks, sha256: item.sha256 }
|
|
1410
|
-
: { objectId: item.objectId }),
|
|
1411
|
-
sourceSize: item.sourceSize,
|
|
1412
|
-
storedSize: item.storedSize,
|
|
1413
|
-
mediaType: item.mediaType,
|
|
1414
|
-
executable: false,
|
|
1415
|
-
})),
|
|
1416
|
-
}),
|
|
1417
|
-
}));
|
|
1418
|
-
/*
|
|
1419
|
-
A clean stale-base save may contain a server-side merge. The candidate
|
|
1420
|
-
manifest built above is then not the Version that landed: it lacks the
|
|
1421
|
-
other publisher's safe changes. Persist the service's accepted snapshot
|
|
1422
|
-
instead. A repeated attempt predates manifest echoing, so read that
|
|
1423
|
-
Version once rather than guessing.
|
|
1424
|
-
*/
|
|
1425
|
-
let acceptedManifest = (0, publication_js_1.acceptedManifestDigests)(completed.manifest?.files);
|
|
1426
|
-
if (!completed.mergeTrack && !Object.keys(acceptedManifest).length) {
|
|
1427
|
-
acceptedManifest = Object.fromEntries([...(await this.versionFiles(repositoryId, completed.version.id))].map(([file, descriptor]) => [file, descriptor.sha256]));
|
|
1428
|
-
}
|
|
1429
|
-
report({
|
|
1430
|
-
stage: "done",
|
|
1431
|
-
files: declarations.length,
|
|
1432
|
-
totalFiles: declarations.length,
|
|
1433
|
-
bytes: sentBytes,
|
|
1520
|
+
return this.publishVersion({
|
|
1521
|
+
request,
|
|
1522
|
+
repositoryId,
|
|
1523
|
+
declarations,
|
|
1524
|
+
uploaded,
|
|
1525
|
+
reused,
|
|
1526
|
+
prior,
|
|
1527
|
+
everything,
|
|
1528
|
+
ticked,
|
|
1529
|
+
deletions,
|
|
1530
|
+
readFailures,
|
|
1531
|
+
vanished,
|
|
1434
1532
|
totalBytes,
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
bytesPerSecond: rate(),
|
|
1533
|
+
progress,
|
|
1534
|
+
report,
|
|
1438
1535
|
});
|
|
1439
|
-
return {
|
|
1440
|
-
repositoryId,
|
|
1441
|
-
versionId: completed.version.id,
|
|
1442
|
-
sequence: completed.version.sequence,
|
|
1443
|
-
...(completed.version.state === "held" ? { held: true } : {}),
|
|
1444
|
-
...(completed.mergeTrack ? { mergeTrack: completed.mergeTrack } : {}),
|
|
1445
|
-
...(completed.repeated ? { repeated: true } : {}),
|
|
1446
|
-
sourceBytes: completed.version.sourceSize ?? sourceBytes,
|
|
1447
|
-
storedBytes: completed.version.storedSize ?? storedBytes,
|
|
1448
|
-
sentBytes: transferred,
|
|
1449
|
-
sentFiles: uploaded.length - alreadyOnAccount,
|
|
1450
|
-
reusedFiles: reused.length,
|
|
1451
|
-
/** Selected, but the service already held the content. */
|
|
1452
|
-
alreadyStoredFiles: alreadyOnAccount,
|
|
1453
|
-
telemetry: this.telemetry.snapshot(),
|
|
1454
|
-
// A file that kept its old object records the digest of *that* copy,
|
|
1455
|
-
// not of the file on disk, so an unticked edit is still pending next
|
|
1456
|
-
// time rather than looking as though it had been saved.
|
|
1457
|
-
/*
|
|
1458
|
-
What this folder holds after the save. A file that was sent holds
|
|
1459
|
-
the bytes that were sent. Anything else keeps whatever digest was
|
|
1460
|
-
recorded before: an unticked edit therefore stays pending rather
|
|
1461
|
-
than looking saved, and a copy left stale by somebody else's merge
|
|
1462
|
-
stays recognisably stale rather than looking like a new edit.
|
|
1463
|
-
*/
|
|
1464
|
-
local: Object.fromEntries(everything.flatMap((file) => {
|
|
1465
|
-
const sent = declarations.find((one) => one.path === file.path);
|
|
1466
|
-
if (sent)
|
|
1467
|
-
return [[file.path, sent.sha256]];
|
|
1468
|
-
const before = request.known?.[file.path] ?? prior.get(file.path)?.sha256;
|
|
1469
|
-
return before ? [[file.path, before]] : [];
|
|
1470
|
-
})),
|
|
1471
|
-
manifest: Object.keys(acceptedManifest).length
|
|
1472
|
-
? acceptedManifest
|
|
1473
|
-
: {
|
|
1474
|
-
...Object.fromEntries(reused.map((file) => [file.path, prior.get(file.path).sha256])),
|
|
1475
|
-
...Object.fromEntries(declarations.map((declaration) => [declaration.path, declaration.sha256])),
|
|
1476
|
-
},
|
|
1477
|
-
};
|
|
1478
1536
|
}
|
|
1479
1537
|
/** What the newest version holds, so unchanged files can point at it. */
|
|
1480
1538
|
async versionFiles(repositoryId, versionId) {
|