@coderook/cli 0.12.0 → 0.14.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.
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+ /**
3
+ * Knowing the whole project before uploading any of it, then sending it in
4
+ * bounded stages.
5
+ *
6
+ * Two separate failures made this necessary.
7
+ *
8
+ * The first was silent. `changedFiles` stops after twenty thousand rows —
9
+ * correctly, because that is a limit on what a list can usefully show — and
10
+ * the upload used the same function to decide what to send. A project past
11
+ * that many files produced a version containing whatever the walk reached
12
+ * first, with nothing anywhere saying the rest existed. A limit meant for a
13
+ * scrollbar was silently deciding what got backed up.
14
+ *
15
+ * The second was unbounded work. Everything was sent in one pass, so memory,
16
+ * progress and the blast radius of an interruption all scaled with the size of
17
+ * the project. A ten gigabyte upload was one attempt that either finished or
18
+ * did not.
19
+ *
20
+ * So: survey everything first, cheaply and without a cap, then divide it into
21
+ * sections with a fixed byte budget. A small project is one section and behaves
22
+ * exactly as it did. A large one is many, each finishing before the next
23
+ * begins, which keeps the working set the same size whatever the project is.
24
+ */
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.SECTION_FILES = exports.SECTION_BYTES = exports.DIRECT_LIMIT = exports.CHUNK_THRESHOLD = exports.PACKABLE_LIMIT = void 0;
27
+ exports.bandOf = bandOf;
28
+ exports.survey = survey;
29
+ exports.planSections = planSections;
30
+ exports.describePlan = describePlan;
31
+ /**
32
+ * How each file travels, decided by size alone.
33
+ *
34
+ * The bands are the existing routes named: small files compress together into
35
+ * a pack, ordinary ones go as their own object, large ones are cut into
36
+ * content-defined chunks, and anything past the direct limit needs a multipart
37
+ * session. Naming them here means the plan can be inspected before a byte
38
+ * moves.
39
+ */
40
+ exports.PACKABLE_LIMIT = 64 * 1024;
41
+ exports.CHUNK_THRESHOLD = 16 * 1024 * 1024;
42
+ exports.DIRECT_LIMIT = 95 * 1024 * 1024;
43
+ function bandOf(size) {
44
+ if (size <= exports.PACKABLE_LIMIT)
45
+ return "packable";
46
+ if (size <= exports.CHUNK_THRESHOLD)
47
+ return "ordinary";
48
+ if (size <= exports.DIRECT_LIMIT)
49
+ return "chunked";
50
+ return "multipart";
51
+ }
52
+ function survey(files) {
53
+ const bands = { packable: 0, ordinary: 0, chunked: 0, multipart: 0 };
54
+ let totalBytes = 0;
55
+ for (const file of files) {
56
+ bands[bandOf(file.size)] += 1;
57
+ totalBytes += file.size;
58
+ }
59
+ return { files, totalBytes, bands };
60
+ }
61
+ /**
62
+ * How much source a single section may carry.
63
+ *
64
+ * Chosen so the working set stays the same whatever the project is: a section
65
+ * is read, packed, sent and finished before the next one starts, so a thirty
66
+ * gigabyte upload holds no more in memory than a two hundred megabyte one. Big
67
+ * enough that a small project is still one section and behaves exactly as it
68
+ * did before any of this existed.
69
+ */
70
+ exports.SECTION_BYTES = 256 * 1024 * 1024;
71
+ /** How many files one section may carry, however small they are. */
72
+ exports.SECTION_FILES = 4000;
73
+ /**
74
+ * Divide a survey into sections.
75
+ *
76
+ * Files are grouped by band first, so a section is mostly one kind of work —
77
+ * a pack of small files, or a run of large ones — rather than a mixture that
78
+ * makes progress lurch. Within a band they keep their order, so a resumed
79
+ * upload repeats the same plan and skips the same completed sections.
80
+ *
81
+ * A file larger than the whole budget gets a section to itself. It cannot be
82
+ * split across sections, and pretending otherwise would either overflow the
83
+ * budget silently or strand the file.
84
+ */
85
+ function planSections(files, budget = exports.SECTION_BYTES, fileLimit = exports.SECTION_FILES) {
86
+ const order = ["packable", "ordinary", "chunked", "multipart"];
87
+ const grouped = new Map();
88
+ for (const file of files) {
89
+ const band = bandOf(file.size);
90
+ const held = grouped.get(band) ?? [];
91
+ held.push(file);
92
+ grouped.set(band, held);
93
+ }
94
+ const sections = [];
95
+ let current = [];
96
+ let bytes = 0;
97
+ const close = () => {
98
+ if (!current.length)
99
+ return;
100
+ sections.push({ index: sections.length, files: current, bytes });
101
+ current = [];
102
+ bytes = 0;
103
+ };
104
+ for (const band of order) {
105
+ for (const file of grouped.get(band) ?? []) {
106
+ /*
107
+ One file bigger than the budget is its own section. Splitting it here
108
+ would mean a section that overflows or a file that never fits, and the
109
+ chunker below the plan already knows how to send it in pieces.
110
+ */
111
+ if (file.size >= budget) {
112
+ close();
113
+ sections.push({ index: sections.length, files: [file], bytes: file.size });
114
+ continue;
115
+ }
116
+ if (bytes + file.size > budget || current.length >= fileLimit)
117
+ close();
118
+ current.push(file);
119
+ bytes += file.size;
120
+ }
121
+ /*
122
+ Sections do not straddle bands. A section of small files and a section of
123
+ large ones are different shapes of work, and mixing them makes the time
124
+ one takes unpredictable from its size.
125
+ */
126
+ close();
127
+ }
128
+ close();
129
+ return sections;
130
+ }
131
+ /** A short description of the plan, for somebody deciding whether to start. */
132
+ function describePlan(surveyed, sections) {
133
+ const mb = (bytes) => `${(bytes / 1048576).toFixed(1)} MB`;
134
+ return [
135
+ `${surveyed.files.length} files, ${mb(surveyed.totalBytes)}`,
136
+ `${sections.length} section${sections.length === 1 ? "" : "s"}`,
137
+ `${surveyed.bands.packable} packed, ${surveyed.bands.ordinary} ordinary, ` +
138
+ `${surveyed.bands.chunked} chunked, ${surveyed.bands.multipart} multipart`,
139
+ ].join(" · ");
140
+ }
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Tracks = void 0;
4
+ const identify_js_1 = require("./identify.js");
5
+ const retry_js_1 = require("./retry.js");
6
+ class Tracks {
7
+ credentials;
8
+ constructor(credentials) {
9
+ this.credentials = credentials;
10
+ }
11
+ async call(route, init) {
12
+ let wait = retry_js_1.RETRY_FIRST_WAIT_MS;
13
+ for (let attempt = 1;; attempt += 1) {
14
+ try {
15
+ return await this.attempt(route, init);
16
+ }
17
+ catch (error) {
18
+ if (attempt >= retry_js_1.RETRY_ATTEMPTS || !(0, retry_js_1.worthRetrying)(error))
19
+ throw error;
20
+ await (0, retry_js_1.pauseFor)(AbortSignal.timeout(wait * 4), wait).catch(() => undefined);
21
+ wait *= 2;
22
+ }
23
+ }
24
+ }
25
+ async attempt(route, init) {
26
+ const token = await this.credentials.token();
27
+ if (!token)
28
+ throw new Error("Sign in again");
29
+ const response = await fetch(`${this.credentials.origin()}${route}`, {
30
+ method: init?.method ?? "GET",
31
+ headers: {
32
+ accept: "application/json",
33
+ authorization: `Bearer ${token}`,
34
+ "user-agent": "CodeRook/0.1",
35
+ ...(0, identify_js_1.clientHeaders)(),
36
+ ...(init?.body ? { "content-type": "application/json" } : {}),
37
+ },
38
+ ...(init?.body ? { body: init.body } : {}),
39
+ });
40
+ const text = await response.text();
41
+ if (!response.ok) {
42
+ let message = `${route} failed (${response.status})`;
43
+ if (text.trimStart().startsWith("{")) {
44
+ try {
45
+ message =
46
+ JSON.parse(text).error
47
+ ?.message ?? message;
48
+ }
49
+ catch {
50
+ /* keep the generic message */
51
+ }
52
+ }
53
+ throw Object.assign(new Error(message), { status: response.status });
54
+ }
55
+ return (text ? JSON.parse(text) : {});
56
+ }
57
+ /**
58
+ * Every line and merge this project has.
59
+ *
60
+ * Answers an empty list rather than throwing when the account cannot be
61
+ * reached: the tracks panel is context, and a project that cannot be
62
+ * examined offline should still open.
63
+ */
64
+ async list(repositoryId) {
65
+ try {
66
+ const body = await this.call(`/v1/repositories/${repositoryId}/tracks`);
67
+ return body.tracks ?? [];
68
+ }
69
+ catch {
70
+ return [];
71
+ }
72
+ }
73
+ /**
74
+ * Start a new line, from wherever the project currently is.
75
+ *
76
+ * The service decides the starting point and refuses a name it cannot
77
+ * accept, so this passes the name through rather than pre-judging it —
78
+ * a rule enforced in two places is a rule that will disagree with itself.
79
+ */
80
+ async create(repositoryId, name) {
81
+ const body = await this.call(`/v1/repositories/${repositoryId}/tracks`, { method: "POST", body: JSON.stringify({ name }) });
82
+ return body.track;
83
+ }
84
+ /** Merges that have not been applied or abandoned. */
85
+ async merges(repositoryId) {
86
+ try {
87
+ const body = await this.call(`/v1/repositories/${repositoryId}/merge-tracks`);
88
+ return (body.mergeTracks ?? []).filter((one) => one.state === "open" || one.state === "applying");
89
+ }
90
+ catch {
91
+ return [];
92
+ }
93
+ }
94
+ /** One merge and every path it is waiting on. */
95
+ async merge(mergeTrackId) {
96
+ const body = await this.call(`/v1/merge-tracks/${mergeTrackId}`);
97
+ const { conflicts = [], ...track } = body;
98
+ return { track: track, conflicts };
99
+ }
100
+ /** Settle one path. */
101
+ async resolve(mergeTrackId, conflictId, resolution) {
102
+ await this.call(`/v1/merge-tracks/${mergeTrackId}/conflicts/${conflictId}`, {
103
+ method: "PUT",
104
+ body: JSON.stringify({ resolution }),
105
+ });
106
+ }
107
+ /**
108
+ * Publish the merge, once every path has been settled.
109
+ *
110
+ * The service refuses while anything is unresolved, which is the check that
111
+ * matters — this is only the request.
112
+ */
113
+ async apply(mergeTrackId) {
114
+ return this.call(`/v1/merge-tracks/${mergeTrackId}/apply`, {
115
+ method: "POST",
116
+ body: "{}",
117
+ });
118
+ }
119
+ /**
120
+ * Abandon it.
121
+ *
122
+ * The candidate's versions are not deleted — they stay on their own track,
123
+ * so nothing that was uploaded is lost by deciding not to merge it.
124
+ */
125
+ async cancel(mergeTrackId) {
126
+ await this.call(`/v1/merge-tracks/${mergeTrackId}/cancel`, {
127
+ method: "POST",
128
+ body: "{}",
129
+ });
130
+ }
131
+ }
132
+ exports.Tracks = Tracks;