@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.
@@ -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 is most of the
315
- * upload and holds no source in it. Never recommended, because the same shape
316
- * is also what a genuine dataset looks like, and a project whose work really
317
- * is fifty thousand data files should be asked once and then believed.
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(node_path_1.default.extname(normalised).toLowerCase()))
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
- if (held.files < BULK_FILES)
339
- continue;
340
- if (held.files / files.length < BULK_SHARE)
341
- continue;
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: `${held.files.toLocaleString()} of ${files.length.toLocaleString()} files, ` +
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
- signal: this.controller.signal,
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
- /** Planning runs the real partition/compression choices without body I/O. */
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
- Read in windows, not all at once.
2092
+ Three steps, and each file is read for as long as it has to be.
2065
2093
 
2066
- This used to load the whole file to chunk it, which for the files that
2067
- reach this path — everything past sixteen megabytes — meant an allocation
2068
- the size of the file. A three gigabyte video was a three gigabyte buffer
2069
- and an out-of-memory crash partway through an upload, which is the one
2070
- failure worse than a slow one: it happens after the work.
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
- The window holds at most one read plus one maximum chunk, whatever the
2073
- file is. Exactly how cbx.ts packs a bundle, and for the same reason.
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 chunks = [];
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 tighter than the file lanes because each one holds a piece in
2095
- memory: four in flight of at most a maximum chunk each, rather than six.
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
- Ask about a bounded group of chunks before sending any of them.
2111
-
2112
- The old path asked once per chunk, with four GETs in flight. Besides
2113
- spending one network round trip per piece, an unavailable lookup was
2114
- deliberately treated as a miss, so a transient failure retransmitted
2115
- every otherwise reusable chunk. The website already uses the bulk
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 batch = [];
2123
- const flush = async () => {
2124
- if (!batch.length)
2125
- return;
2126
- const ready = batch.splice(0, batch.length);
2127
- const held = await this.storedInBulk(repositoryId, ready.map((item) => item.digest));
2128
- for (const item of ready) {
2129
- const stored = held.get(item.digest);
2130
- if (stored) {
2131
- note("reused", 0);
2132
- chunks[item.at] = {
2133
- objectId: stored.objectId,
2134
- sourceSize: item.piece.length,
2135
- storedSize: stored.size,
2136
- };
2137
- done += item.piece.length;
2138
- onOffset(done);
2139
- continue;
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
- await dispatch(item.piece, item.at, item.digest);
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
- await Promise.all(active);
2144
- };
2145
- const queue = async (piece) => {
2146
- batch.push({
2147
- piece,
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
- let ordinal = 0;
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
- await queue(Buffer.from(pending.subarray(from, cut)));
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
- await queue(pending);
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
- const encoded = await (0, compress_js_1.encodeForUpload)(new Uint8Array(piece), declaration.path, await this.gzipAllowed());
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}` +