@coderook/cli 0.30.2 → 0.31.1
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 +25 -1
- package/dist/cli/src/config.js +209 -22
- package/dist/cli/src/git_remote.js +105 -16
- package/dist/cli/src/publish.js +5 -0
- package/dist/cli/src/stored_zip.js +85 -0
- package/dist/desktop-app/src/main/compress.js +21 -0
- package/dist/desktop-app/src/main/detect.js +84 -15
- package/dist/desktop-app/src/main/download.js +58 -2
- package/dist/desktop-app/src/main/upload.js +187 -83
- package/dist/desktop-app/src/main/worktree.js +193 -5
- package/dist/desktop-app/src/shared/compression_policy.js +122 -5
- package/dist/desktop-app/src/shared/format.js +31 -0
- package/package.json +1 -1
|
@@ -25,6 +25,7 @@ exports.rulesFromSuggestions = rulesFromSuggestions;
|
|
|
25
25
|
* exactly as long as nothing is applied without somebody saying so.
|
|
26
26
|
*/
|
|
27
27
|
const node_path_1 = __importDefault(require("node:path"));
|
|
28
|
+
const format_js_1 = require("../shared/format.js");
|
|
28
29
|
/*
|
|
29
30
|
Everything here is recoverable from something else in the project, or is
|
|
30
31
|
another program's working state. Nothing that could be somebody's only copy
|
|
@@ -302,6 +303,46 @@ const BULK_FILES = 2_000;
|
|
|
302
303
|
const BULK_SHARE = 0.7;
|
|
303
304
|
/** Above this share of hand-written files it is source, not output. */
|
|
304
305
|
const BULK_SOURCE_SHARE = 0.1;
|
|
306
|
+
/**
|
|
307
|
+
* And the same question asked in bytes, for a folder that is huge but not many.
|
|
308
|
+
*
|
|
309
|
+
* Counting files misses the shape that actually stops a save finishing. A real
|
|
310
|
+
* project — 253 files — kept thirteen `.gguf` model weights in `output/`: 73
|
|
311
|
+
* files, 10.67 GB, 99% of everything it would have sent and 29% of its file
|
|
312
|
+
* count. Every threshold above is about counts, so nothing was suggested, and
|
|
313
|
+
* the app set out to upload 10.7 GB of rebuildable model output without a word.
|
|
314
|
+
* It never finished, and the project still has no version.
|
|
315
|
+
*
|
|
316
|
+
* A gigabyte is the floor because below it the upload is not the problem and
|
|
317
|
+
* the question is not worth asking. The share is the same 0.7: a folder that is
|
|
318
|
+
* most of the bytes, whatever its file count.
|
|
319
|
+
*/
|
|
320
|
+
const BULK_BYTES = 1024 * 1024 * 1024;
|
|
321
|
+
/*
|
|
322
|
+
Media somebody made, as opposed to bytes a machine emitted.
|
|
323
|
+
|
|
324
|
+
Needed only by the size test, and needed badly there. `SOURCE_EXTENSIONS`
|
|
325
|
+
above asks "did a person type this", which is the right question for a folder
|
|
326
|
+
judged on its file count — a cache holds no `.ts` files. It is the wrong
|
|
327
|
+
question for a folder judged on its size, because a game's art holds no `.ts`
|
|
328
|
+
files either, and by bytes the art is almost always most of the project. The
|
|
329
|
+
count rule has a calibration case for exactly that folder; a size rule without
|
|
330
|
+
this list would walk straight back into it and offer to leave the game out.
|
|
331
|
+
*/
|
|
332
|
+
const AUTHORED_EXTENSIONS = new Set([
|
|
333
|
+
".aseprite", ".blend", ".bmp", ".dds", ".exr", ".fbx", ".flac", ".gif",
|
|
334
|
+
".glb", ".gltf", ".hdr", ".jpeg", ".jpg", ".m4a", ".mov", ".mp3", ".mp4",
|
|
335
|
+
".obj", ".ogg", ".otf", ".pdf", ".png", ".psd", ".svg", ".tga", ".tif",
|
|
336
|
+
".tiff", ".ttf", ".wav", ".webm", ".webp", ".woff", ".woff2",
|
|
337
|
+
]);
|
|
338
|
+
/**
|
|
339
|
+
* Above this share of a folder's *bytes* it is a library of made things.
|
|
340
|
+
*
|
|
341
|
+
* By bytes rather than by file count, because that is the currency the test it
|
|
342
|
+
* guards is arguing in: one 4 GB video beside a thousand generated logs is a
|
|
343
|
+
* folder of video, and counting files would call it a folder of logs.
|
|
344
|
+
*/
|
|
345
|
+
const AUTHORED_BYTE_SHARE = 0.5;
|
|
305
346
|
/**
|
|
306
347
|
* One enormous folder that no list of names could have known about.
|
|
307
348
|
*
|
|
@@ -309,42 +350,70 @@ const BULK_SOURCE_SHARE = 0.1;
|
|
|
309
350
|
* things every project has and not at all for the one a person invented. This
|
|
310
351
|
* repository's own `work/` is 63,210 of the 64,554 files it would otherwise
|
|
311
352
|
* send — scratch output from a test harness, named nothing in particular, and
|
|
312
|
-
* invisible to every rule in this file.
|
|
353
|
+
* invisible to every rule in this file. Another project's `output/` is 10.67 GB
|
|
354
|
+
* of model weights in 73 files, which no count-based threshold can see at all.
|
|
313
355
|
*
|
|
314
|
-
* So this asks about shape instead of name: one folder that
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
356
|
+
* So this asks about shape instead of name, in both currencies: one folder that
|
|
357
|
+
* is most of what would be sent — by file count or by size — and holds no
|
|
358
|
+
* source in it. Never recommended, because the same shape is also what a
|
|
359
|
+
* genuine dataset looks like, and a project whose work really is fifty thousand
|
|
360
|
+
* data files should be asked once and then believed.
|
|
318
361
|
*/
|
|
319
362
|
function suggestBulkDirectories(files) {
|
|
320
|
-
if (files.length < BULK_FILES)
|
|
321
|
-
return [];
|
|
322
363
|
const tally = new Map();
|
|
364
|
+
let totalBytes = 0;
|
|
323
365
|
for (const entry of files) {
|
|
324
366
|
const normalised = entry.path.split("\\").join("/");
|
|
367
|
+
totalBytes += entry.size;
|
|
325
368
|
const top = normalised.includes("/") ? normalised.slice(0, normalised.indexOf("/")) : "";
|
|
326
369
|
/* A file at the root belongs to no folder, so it cannot be one of these. */
|
|
327
370
|
if (!top)
|
|
328
371
|
continue;
|
|
329
|
-
const held = tally.get(top) ?? { bytes: 0, files: 0, source: 0 };
|
|
372
|
+
const held = tally.get(top) ?? { bytes: 0, files: 0, source: 0, authored: 0 };
|
|
373
|
+
const extension = node_path_1.default.extname(normalised).toLowerCase();
|
|
330
374
|
held.bytes += entry.size;
|
|
331
375
|
held.files += 1;
|
|
332
|
-
if (SOURCE_EXTENSIONS.has(
|
|
376
|
+
if (SOURCE_EXTENSIONS.has(extension))
|
|
333
377
|
held.source += 1;
|
|
378
|
+
if (AUTHORED_EXTENSIONS.has(extension))
|
|
379
|
+
held.authored += entry.size;
|
|
334
380
|
tally.set(top, held);
|
|
335
381
|
}
|
|
336
382
|
const found = [];
|
|
337
383
|
for (const [top, held] of tally) {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
384
|
+
/*
|
|
385
|
+
Whatever else is true of it, a folder with source in it is somebody's
|
|
386
|
+
work. This is the one test both shapes below have to pass.
|
|
387
|
+
*/
|
|
342
388
|
if (held.source / held.files >= BULK_SOURCE_SHARE)
|
|
343
389
|
continue;
|
|
390
|
+
const byCount = files.length >= BULK_FILES &&
|
|
391
|
+
held.files >= BULK_FILES &&
|
|
392
|
+
held.files / files.length >= BULK_SHARE;
|
|
393
|
+
const byBytes = held.bytes >= BULK_BYTES &&
|
|
394
|
+
held.bytes / Math.max(totalBytes, 1) >= BULK_SHARE &&
|
|
395
|
+
/*
|
|
396
|
+
And it is not a library of made things. A game's art is most of a game
|
|
397
|
+
by size and is the last thing anybody wants left out of the save.
|
|
398
|
+
*/
|
|
399
|
+
held.authored / Math.max(held.bytes, 1) < AUTHORED_BYTE_SHARE;
|
|
400
|
+
if (!byCount && !byBytes)
|
|
401
|
+
continue;
|
|
402
|
+
/*
|
|
403
|
+
Described in whichever currency makes the case. "73 of 253 files" is not
|
|
404
|
+
an argument for leaving anything out; "10.7 GB of 10.8 GB" is the entire
|
|
405
|
+
argument, and the person reading it is deciding whether to spend an
|
|
406
|
+
evening uploading. Where both are true, size is still the thing that
|
|
407
|
+
hurts, so size is what gets said.
|
|
408
|
+
*/
|
|
409
|
+
const reason = byBytes
|
|
410
|
+
? `${(0, format_js_1.formatBytes)(held.bytes)} of ${(0, format_js_1.formatBytes)(totalBytes)}, ` +
|
|
411
|
+
`and no source in it — generated?`
|
|
412
|
+
: `${held.files.toLocaleString()} of ${files.length.toLocaleString()} files, ` +
|
|
413
|
+
`and no source in it — generated?`;
|
|
344
414
|
found.push({
|
|
345
415
|
pattern: `${top}/`,
|
|
346
|
-
reason
|
|
347
|
-
`and no source in it — generated?`,
|
|
416
|
+
reason,
|
|
348
417
|
bytes: held.bytes,
|
|
349
418
|
files: held.files,
|
|
350
419
|
recommended: false,
|
|
@@ -61,6 +61,23 @@ async function pruneEmpty(root, removed) {
|
|
|
61
61
|
directory = node_path_1.default.dirname(directory);
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* How long one request may go unanswered before it is treated as lost.
|
|
66
|
+
*
|
|
67
|
+
* The same three minutes the uploader uses, and for the same reason: what
|
|
68
|
+
* follows a deadline here is a retry, so the cost of it firing early on a
|
|
69
|
+
* genuinely slow call is one repeated request.
|
|
70
|
+
*/
|
|
71
|
+
const REQUEST_DEADLINE_MS = 180_000;
|
|
72
|
+
/**
|
|
73
|
+
* And how long a whole-version archive may take to arrive.
|
|
74
|
+
*
|
|
75
|
+
* The service reads every file in the version out of object storage to build
|
|
76
|
+
* it, so the wait is proportional to the project rather than to the network.
|
|
77
|
+
* Fifteen minutes is far beyond any archive measured and still an answer
|
|
78
|
+
* rather than an indefinite wait.
|
|
79
|
+
*/
|
|
80
|
+
const ARCHIVE_DEADLINE_MS = 900_000;
|
|
64
81
|
/**
|
|
65
82
|
* A percentage for work that is still going.
|
|
66
83
|
*
|
|
@@ -127,7 +144,18 @@ class Downloader {
|
|
|
127
144
|
}
|
|
128
145
|
}
|
|
129
146
|
}
|
|
130
|
-
async attempt(route
|
|
147
|
+
async attempt(route,
|
|
148
|
+
/*
|
|
149
|
+
Longer where the service has to build the answer before it can send it.
|
|
150
|
+
|
|
151
|
+
A deadline sized for an ordinary request aborts a large archive halfway
|
|
152
|
+
through making it, and the git import quietly falls back to fetching
|
|
153
|
+
every file on its own — so the fix for a slow clone became the cause of
|
|
154
|
+
one. The archive for a 21,071-file version takes about ninety seconds
|
|
155
|
+
to assemble and longer under load; three minutes is a request that has
|
|
156
|
+
failed, not one that is still working.
|
|
157
|
+
*/
|
|
158
|
+
deadline = REQUEST_DEADLINE_MS) {
|
|
131
159
|
const token = await this.credentials.token();
|
|
132
160
|
if (!token)
|
|
133
161
|
throw new Error("Sign in again before downloading");
|
|
@@ -137,7 +165,19 @@ class Downloader {
|
|
|
137
165
|
"user-agent": "CodeRook/0.1",
|
|
138
166
|
...(0, identify_js_1.clientHeaders)(),
|
|
139
167
|
},
|
|
140
|
-
|
|
168
|
+
/*
|
|
169
|
+
The download's own cancellation, and a deadline.
|
|
170
|
+
|
|
171
|
+
This carried only the cancel signal, so a request the service never
|
|
172
|
+
answered waited for ever — the same defect that was fixed in the
|
|
173
|
+
uploader and in the command line's own client, and missed here. A
|
|
174
|
+
restore that stops with no bytes moving and no message is the worse
|
|
175
|
+
half of it: the person is left without the thing they were restoring.
|
|
176
|
+
*/
|
|
177
|
+
signal: AbortSignal.any([
|
|
178
|
+
this.controller.signal,
|
|
179
|
+
AbortSignal.timeout(deadline),
|
|
180
|
+
]),
|
|
141
181
|
});
|
|
142
182
|
if (!response.ok) {
|
|
143
183
|
const text = await response.text().catch(() => "");
|
|
@@ -217,6 +257,22 @@ class Downloader {
|
|
|
217
257
|
authorName: String(row.authorName ?? ""),
|
|
218
258
|
}));
|
|
219
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* A whole version as one archive, for a caller that wants all of it.
|
|
262
|
+
*
|
|
263
|
+
* The git import asked for each file in its own request, which on a
|
|
264
|
+
* 21,071-file project is 21,071 round trips — four hours in series, and
|
|
265
|
+
* about half an hour even eight at a time. The service already builds the
|
|
266
|
+
* whole version as a single stored archive for the download button; this
|
|
267
|
+
* is that, for a caller that would otherwise ask file by file.
|
|
268
|
+
*
|
|
269
|
+
* Returned whole rather than streamed because the caller is handing the
|
|
270
|
+
* files to `git fast-import`, which wants them one after another anyway.
|
|
271
|
+
*/
|
|
272
|
+
async archive(repositoryId, versionId) {
|
|
273
|
+
const response = await this.attempt(`/v1/repositories/${repositoryId}/versions/${versionId}/archive`, ARCHIVE_DEADLINE_MS);
|
|
274
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
275
|
+
}
|
|
220
276
|
async files(repositoryId, versionId) {
|
|
221
277
|
const body = await this.json(`/v1/repositories/${repositoryId}/versions/${versionId}/files`);
|
|
222
278
|
return (body.files ?? []).map((row) => {
|
|
@@ -21,6 +21,7 @@ const identify_js_1 = require("./identify.js");
|
|
|
21
21
|
const worktree_js_1 = require("./worktree.js");
|
|
22
22
|
const publish_name_js_1 = require("./publish_name.js");
|
|
23
23
|
const compress_js_1 = require("./compress.js");
|
|
24
|
+
const compression_policy_js_1 = require("../shared/compression_policy.js");
|
|
24
25
|
const chunking_js_1 = require("../shared/chunking.js");
|
|
25
26
|
const publication_js_1 = require("../shared/publication.js");
|
|
26
27
|
const profile_js_1 = require("./profile.js");
|
|
@@ -125,9 +126,21 @@ class Uploader {
|
|
|
125
126
|
/** One shared preflight, so parallel upload lanes cannot race /health. */
|
|
126
127
|
capabilityRequest = null;
|
|
127
128
|
telemetry = new telemetry_js_1.RepositoryTelemetry();
|
|
128
|
-
|
|
129
|
+
/*
|
|
130
|
+
Planning makes the real partition and compression choices, and for a
|
|
131
|
+
chunked file that means reading it: a chunk's identity is the digest of its
|
|
132
|
+
bytes, and the quote has to name every one. This comment used to say the
|
|
133
|
+
plan did no body I/O, which was never true, and the belief that it was is
|
|
134
|
+
how the send came to do all of that reading a second time.
|
|
135
|
+
*/
|
|
129
136
|
planning = false;
|
|
130
137
|
plannedObjects = new Map();
|
|
138
|
+
/**
|
|
139
|
+
* Where each chunked file was cut, as the plan found it, so the send does not
|
|
140
|
+
* cut it again. Keyed by path and whole-file digest: a file that changed in
|
|
141
|
+
* between has a different digest, finds nothing here, and is walked afresh.
|
|
142
|
+
*/
|
|
143
|
+
plannedLayouts = new Map();
|
|
131
144
|
plannedQuote = null;
|
|
132
145
|
/*
|
|
133
146
|
Digests worked out while planning, so sending does not work them out again.
|
|
@@ -145,6 +158,20 @@ class Uploader {
|
|
|
145
158
|
/** Whether this save has already said that packing was refused. */
|
|
146
159
|
packRefusalSaid = false;
|
|
147
160
|
plannedDigests = new Map();
|
|
161
|
+
/**
|
|
162
|
+
* Every digest this save actually took, for a caller that keeps a record.
|
|
163
|
+
*
|
|
164
|
+
* The desktop keeps one and hands it back through `knownDigests`, which is
|
|
165
|
+
* what stops an unchanged file being read twice. The command line had no
|
|
166
|
+
* way to keep one because this was private, so it re-read and re-hashed
|
|
167
|
+
* whole projects on every save.
|
|
168
|
+
*
|
|
169
|
+
* Only files that were read are in here — an entry served from the caller's
|
|
170
|
+
* own record was never taken and does not need writing down again.
|
|
171
|
+
*/
|
|
172
|
+
digestsTaken() {
|
|
173
|
+
return new Map(this.plannedDigests);
|
|
174
|
+
}
|
|
148
175
|
planningCreatedRepositoryId = null;
|
|
149
176
|
/** Attached to every object write after the server admits the exact plan. */
|
|
150
177
|
activeQuoteId = null;
|
|
@@ -306,6 +333,7 @@ class Uploader {
|
|
|
306
333
|
this.plannedQuote = null;
|
|
307
334
|
this.planningCreatedRepositoryId = null;
|
|
308
335
|
this.plannedObjects.clear();
|
|
336
|
+
this.plannedLayouts.clear();
|
|
309
337
|
try {
|
|
310
338
|
await this.runPass(request, report);
|
|
311
339
|
const quote = this.plannedQuote;
|
|
@@ -2061,18 +2089,36 @@ class Uploader {
|
|
|
2061
2089
|
*/
|
|
2062
2090
|
async putChunked(repositoryId, declaration, profile, onOffset) {
|
|
2063
2091
|
/*
|
|
2064
|
-
|
|
2092
|
+
Three steps, and each file is read for as long as it has to be.
|
|
2065
2093
|
|
|
2066
|
-
This used to
|
|
2067
|
-
|
|
2068
|
-
the
|
|
2069
|
-
|
|
2070
|
-
|
|
2094
|
+
This used to walk the file once, asking the service about every four
|
|
2095
|
+
chunks as it went and waiting for each answer before reading on — then
|
|
2096
|
+
the send walked it again and asked all over again. A 10.6 GB project is
|
|
2097
|
+
8,395 chunks: 2,099 lookups per pass, one after another, at about a
|
|
2098
|
+
third of a second each. Twelve minutes of waiting per pass, on a stage
|
|
2099
|
+
the window labels "Working out what to send", before a byte had moved.
|
|
2071
2100
|
|
|
2072
|
-
|
|
2073
|
-
|
|
2101
|
+
Now: find where the file is cut and what each piece's digest is (the plan
|
|
2102
|
+
does this; the send is handed the plan's answer), ask about every digest
|
|
2103
|
+
at once — one request per five thousand — and then read only the pieces
|
|
2104
|
+
that are not already held, by position, several at a time.
|
|
2074
2105
|
*/
|
|
2075
|
-
const
|
|
2106
|
+
const key = `${declaration.path}\n${declaration.sha256}`;
|
|
2107
|
+
let layout = this.planning ? undefined : this.plannedLayouts.get(key);
|
|
2108
|
+
/*
|
|
2109
|
+
Walking the file is half this file's progress when it happens here and
|
|
2110
|
+
none of it when the plan already did it, so the bar neither jumps back
|
|
2111
|
+
between the two halves nor sits still through the first.
|
|
2112
|
+
*/
|
|
2113
|
+
const walking = !layout;
|
|
2114
|
+
if (!layout) {
|
|
2115
|
+
layout = await this.layoutOf(declaration, profile, (offset) => onOffset(Math.floor(offset / 2)));
|
|
2116
|
+
if (this.planning)
|
|
2117
|
+
this.plannedLayouts.set(key, layout);
|
|
2118
|
+
}
|
|
2119
|
+
const already = walking ? declaration.size / 2 : 0;
|
|
2120
|
+
const share = walking ? 0.5 : 1;
|
|
2121
|
+
const chunks = new Array(layout.length);
|
|
2076
2122
|
let reused = 0;
|
|
2077
2123
|
let sent = 0;
|
|
2078
2124
|
let done = 0;
|
|
@@ -2082,6 +2128,12 @@ class Uploader {
|
|
|
2082
2128
|
else
|
|
2083
2129
|
sent += bytes;
|
|
2084
2130
|
};
|
|
2131
|
+
/*
|
|
2132
|
+
Asked all at once, because nothing about the question needs the bytes.
|
|
2133
|
+
The old batch was capped at four because each entry held a piece in
|
|
2134
|
+
memory; the digests alone are sixty-four characters each.
|
|
2135
|
+
*/
|
|
2136
|
+
const held = await this.storedInBulk(repositoryId, layout.map((piece) => piece.digest));
|
|
2085
2137
|
/*
|
|
2086
2138
|
A file's chunks go up several at a time.
|
|
2087
2139
|
|
|
@@ -2091,68 +2143,108 @@ class Uploader {
|
|
|
2091
2143
|
out and only the big one was left — five lanes idle while one crawled.
|
|
2092
2144
|
For a folder that is *one* large file, that was the whole upload.
|
|
2093
2145
|
|
|
2094
|
-
Bounded
|
|
2095
|
-
|
|
2146
|
+
Bounded because each lane holds one piece in memory: four in flight of
|
|
2147
|
+
at most a maximum chunk each.
|
|
2096
2148
|
*/
|
|
2097
2149
|
const CHUNK_LANES = 4;
|
|
2098
|
-
const active = new Set();
|
|
2099
|
-
const dispatch = async (piece, at, digest) => {
|
|
2100
|
-
const task = this.sendChunk(repositoryId, declaration, piece, digest, chunks, at, note).finally(() => {
|
|
2101
|
-
active.delete(task);
|
|
2102
|
-
done += piece.length;
|
|
2103
|
-
onOffset(done);
|
|
2104
|
-
});
|
|
2105
|
-
active.add(task);
|
|
2106
|
-
if (active.size >= CHUNK_LANES)
|
|
2107
|
-
await Promise.race(active);
|
|
2108
|
-
};
|
|
2109
2150
|
/*
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
endpoint. Reusing it here gives Desktop and CLI the same wire behaviour
|
|
2117
|
-
while retaining the safe "send it if unsure" fallback.
|
|
2118
|
-
|
|
2119
|
-
A batch is no larger than the existing chunk lane count, so this does
|
|
2120
|
-
not increase the memory ceiling: at most four bounded pieces are held.
|
|
2151
|
+
One verdict for this file, shared by its lanes, and consulted only where
|
|
2152
|
+
the plan has not already decided. The send takes each chunk's encoding
|
|
2153
|
+
from the plan instead, because the service holds every object to exactly
|
|
2154
|
+
what was quoted and rejects anything else — and this verdict depends on
|
|
2155
|
+
which of four lanes finishes compressing first, so two passes asking it
|
|
2156
|
+
separately could disagree about a chunk right where it gives up.
|
|
2121
2157
|
*/
|
|
2122
|
-
const
|
|
2123
|
-
const
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
const
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2158
|
+
const gzip = (0, compression_policy_js_1.freshGzipVerdict)();
|
|
2159
|
+
const pieces = layout;
|
|
2160
|
+
const file = await (0, promises_1.open)(declaration.full, "r");
|
|
2161
|
+
try {
|
|
2162
|
+
let cursor = 0;
|
|
2163
|
+
const lane = async () => {
|
|
2164
|
+
for (;;) {
|
|
2165
|
+
const at = cursor;
|
|
2166
|
+
cursor += 1;
|
|
2167
|
+
if (at >= pieces.length)
|
|
2168
|
+
return;
|
|
2169
|
+
this.check();
|
|
2170
|
+
const piece = pieces[at];
|
|
2171
|
+
const stored = held.get(piece.digest);
|
|
2172
|
+
if (stored) {
|
|
2173
|
+
note("reused", 0);
|
|
2174
|
+
chunks[at] = {
|
|
2175
|
+
objectId: stored.objectId,
|
|
2176
|
+
sourceSize: piece.length,
|
|
2177
|
+
storedSize: stored.size,
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
2180
|
+
else {
|
|
2181
|
+
const bytes = Buffer.allocUnsafe(piece.length);
|
|
2182
|
+
let got = 0;
|
|
2183
|
+
while (got < piece.length) {
|
|
2184
|
+
const { bytesRead } = await file.read(bytes, got, piece.length - got, piece.offset + got);
|
|
2185
|
+
if (!bytesRead)
|
|
2186
|
+
break;
|
|
2187
|
+
got += bytesRead;
|
|
2188
|
+
}
|
|
2189
|
+
/*
|
|
2190
|
+
Checked, because the layout may be the plan's and the file may
|
|
2191
|
+
have been written to since without its size or time moving. The
|
|
2192
|
+
service would refuse the piece anyway; this says why, in words.
|
|
2193
|
+
*/
|
|
2194
|
+
if (got !== piece.length ||
|
|
2195
|
+
(0, node_crypto_1.createHash)("sha256").update(bytes).digest("hex") !== piece.digest) {
|
|
2196
|
+
throw new Error(`${declaration.path} changed while it was being saved. ` +
|
|
2197
|
+
"Save again to send what it holds now.");
|
|
2198
|
+
}
|
|
2199
|
+
await this.sendChunk(repositoryId, declaration, bytes, piece.digest, chunks, at, note, gzip);
|
|
2200
|
+
}
|
|
2201
|
+
done += piece.length;
|
|
2202
|
+
onOffset(Math.floor(already + done * share));
|
|
2140
2203
|
}
|
|
2141
|
-
|
|
2204
|
+
};
|
|
2205
|
+
await Promise.all(Array.from({ length: Math.min(CHUNK_LANES, pieces.length) }, () => lane()));
|
|
2206
|
+
}
|
|
2207
|
+
finally {
|
|
2208
|
+
await file.close();
|
|
2209
|
+
}
|
|
2210
|
+
/*
|
|
2211
|
+
Order is the file. Every position must be filled: a hole would mean a
|
|
2212
|
+
chunk that never landed, and publishing around it would produce a version
|
|
2213
|
+
whose bytes are individually verified and collectively wrong.
|
|
2214
|
+
|
|
2215
|
+
Counted by index rather than with `some`, which skips the holes of a
|
|
2216
|
+
sparse array — the one thing this check exists to find — and so could
|
|
2217
|
+
never have fired.
|
|
2218
|
+
*/
|
|
2219
|
+
for (let at = 0; at < pieces.length; at += 1) {
|
|
2220
|
+
if (!chunks[at]) {
|
|
2221
|
+
throw new Error(`${declaration.path}: a chunk did not finish uploading`);
|
|
2142
2222
|
}
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
at: ordinal++,
|
|
2149
|
-
digest: (0, node_crypto_1.createHash)("sha256").update(piece).digest("hex"),
|
|
2150
|
-
});
|
|
2151
|
-
if (batch.length >= CHUNK_LANES)
|
|
2152
|
-
await flush();
|
|
2223
|
+
}
|
|
2224
|
+
return {
|
|
2225
|
+
chunks: chunks,
|
|
2226
|
+
reused,
|
|
2227
|
+
sentBytes: sent,
|
|
2153
2228
|
};
|
|
2154
|
-
|
|
2229
|
+
}
|
|
2230
|
+
/**
|
|
2231
|
+
* Where a file is cut into chunks, and the digest of each piece.
|
|
2232
|
+
*
|
|
2233
|
+
* Read in windows, not all at once. This used to load the whole file to
|
|
2234
|
+
* chunk it, which for the files that reach this path meant an allocation the
|
|
2235
|
+
* size of the file: a three gigabyte video was a three gigabyte buffer and an
|
|
2236
|
+
* out-of-memory crash partway through an upload. The window holds at most
|
|
2237
|
+
* one read plus one maximum chunk, whatever the file is — exactly how cbx.ts
|
|
2238
|
+
* packs a bundle, and for the same reason.
|
|
2239
|
+
*
|
|
2240
|
+
* Nothing is kept but offsets and digests, so a ten gigabyte file costs a few
|
|
2241
|
+
* hundred kilobytes to remember between the plan and the send.
|
|
2242
|
+
*/
|
|
2243
|
+
async layoutOf(declaration, profile, onScanned) {
|
|
2244
|
+
const layout = [];
|
|
2245
|
+
const digestOf = (bytes) => (0, node_crypto_1.createHash)("sha256").update(bytes).digest("hex");
|
|
2155
2246
|
let pending = Buffer.alloc(0);
|
|
2247
|
+
let pendingAt = 0;
|
|
2156
2248
|
for await (const block of (0, node_fs_1.createReadStream)(declaration.full, {
|
|
2157
2249
|
highWaterMark: 8 * 1024 * 1024,
|
|
2158
2250
|
})) {
|
|
@@ -2161,10 +2253,16 @@ class Uploader {
|
|
|
2161
2253
|
pending = pending.length ? Buffer.concat([pending, incoming]) : incoming;
|
|
2162
2254
|
let from = 0;
|
|
2163
2255
|
for (const cut of (0, chunking_js_1.cutPoints)(pending, profile)) {
|
|
2164
|
-
|
|
2256
|
+
layout.push({
|
|
2257
|
+
offset: pendingAt + from,
|
|
2258
|
+
length: cut - from,
|
|
2259
|
+
digest: digestOf(pending.subarray(from, cut)),
|
|
2260
|
+
});
|
|
2165
2261
|
from = cut;
|
|
2166
2262
|
}
|
|
2263
|
+
pendingAt += from;
|
|
2167
2264
|
pending = Buffer.from(pending.subarray(from));
|
|
2265
|
+
onScanned(pendingAt);
|
|
2168
2266
|
}
|
|
2169
2267
|
/*
|
|
2170
2268
|
cutPoints returns the boundaries *inside* what it was given and never the
|
|
@@ -2172,23 +2270,10 @@ class Uploader {
|
|
|
2172
2270
|
mention — the packer in cbx.ts emits that tail itself, after its loop.
|
|
2173
2271
|
Dropping it truncates every chunked file by exactly its last piece.
|
|
2174
2272
|
*/
|
|
2175
|
-
if (pending.length)
|
|
2176
|
-
|
|
2177
|
-
await flush();
|
|
2178
|
-
/*
|
|
2179
|
-
Order is the file. Every position must be filled: a hole would mean a
|
|
2180
|
-
chunk that never landed, and publishing around it would produce a version
|
|
2181
|
-
whose bytes are individually verified and collectively wrong.
|
|
2182
|
-
*/
|
|
2183
|
-
const ordered = chunks.slice(0, ordinal);
|
|
2184
|
-
if (ordered.some((one) => !one)) {
|
|
2185
|
-
throw new Error(`${declaration.path}: a chunk did not finish uploading`);
|
|
2273
|
+
if (pending.length) {
|
|
2274
|
+
layout.push({ offset: pendingAt, length: pending.length, digest: digestOf(pending) });
|
|
2186
2275
|
}
|
|
2187
|
-
return
|
|
2188
|
-
chunks: ordered,
|
|
2189
|
-
reused,
|
|
2190
|
-
sentBytes: sent,
|
|
2191
|
-
};
|
|
2276
|
+
return layout;
|
|
2192
2277
|
}
|
|
2193
2278
|
/** Send one chunk, reusing it if the account already holds those bytes. */
|
|
2194
2279
|
async sendChunk(repositoryId, declaration, piece, digest,
|
|
@@ -2199,10 +2284,29 @@ class Uploader {
|
|
|
2199
2284
|
large file out of sequence, which produces bytes that are all present,
|
|
2200
2285
|
all verified individually, and wrong.
|
|
2201
2286
|
*/
|
|
2202
|
-
chunks, at, note
|
|
2287
|
+
chunks, at, note,
|
|
2288
|
+
/** This file's running verdict on compression; see {@link noteChunkOutcome}. */
|
|
2289
|
+
gzip) {
|
|
2203
2290
|
{
|
|
2204
2291
|
this.check();
|
|
2205
|
-
|
|
2292
|
+
/*
|
|
2293
|
+
What the plan decided, where it decided. The quote names each object's
|
|
2294
|
+
encoding and the service rejects a piece that arrives any other way,
|
|
2295
|
+
so the send repeats the plan's answer rather than working one out. The
|
|
2296
|
+
same bytes with the same answer compress to the same bytes, which is
|
|
2297
|
+
what makes repeating it exact.
|
|
2298
|
+
*/
|
|
2299
|
+
const planned = this.planning ? undefined : this.plannedObjects.get(digest);
|
|
2300
|
+
const encoded = await (0, compress_js_1.encodeForUpload)(new Uint8Array(piece), declaration.path, planned
|
|
2301
|
+
? planned.encoding === "gzip"
|
|
2302
|
+
: gzip.allowed && (await this.gzipAllowed()));
|
|
2303
|
+
/*
|
|
2304
|
+
Read the outcome back rather than the guess. A chunk that kept gzip
|
|
2305
|
+
proves the file is worth compressing; a run of chunks that did not
|
|
2306
|
+
proves it is not, and there is no reason to ask the rest of a gigabyte.
|
|
2307
|
+
*/
|
|
2308
|
+
if (!planned)
|
|
2309
|
+
(0, compression_policy_js_1.noteChunkOutcome)(gzip, encoded.encoding === "gzip");
|
|
2206
2310
|
const query = encoded.encoding === "gzip"
|
|
2207
2311
|
? `?kind=chunk&role=chunk&encoding=gzip` +
|
|
2208
2312
|
`&logicalSize=${piece.length}` +
|