@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.
@@ -20,9 +20,56 @@ const faults_js_1 = require("./faults.js");
20
20
  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
+ const compress_js_1 = require("./compress.js");
24
+ const cbx_js_1 = require("./cbx.js");
25
+ const profile_js_1 = require("./profile.js");
26
+ const solid_js_1 = require("./solid.js");
27
+ const retry_js_1 = require("./retry.js");
28
+ const staging_js_1 = require("./staging.js");
23
29
  /** Above this the API insists on a multipart session. */
24
30
  const DIRECT_LIMIT = 95 * 1024 * 1024;
31
+ /*
32
+ Above this a file is cut into content-defined chunks instead of being sent
33
+ whole. Below it the round trips cost more than the chunking saves — the
34
+ chunker's own average chunk is eight megabytes, so a smaller file would
35
+ usually come out as one chunk anyway.
36
+ */
37
+ const CHUNK_THRESHOLD = 16 * 1024 * 1024;
38
+ /*
39
+ How many files are sent at once. Six is the limit browsers settled on per
40
+ host for the same reason: enough to keep the link busy through the latency
41
+ of each request, few enough that a slow service is not buried by a client.
42
+ */
43
+ const UPLOAD_LANES = 6;
44
+ /*
45
+ Below this a file travels with others in one request rather than alone.
46
+
47
+ Measured rather than guessed, twice. The first number came from a source
48
+ folder where ninety-six percent of files were under sixty-four kilobytes.
49
+ A four and a half gigabyte game then showed the opposite shape: eight
50
+ hundred files of three hundred kilobytes per section, each just above that
51
+ line and so each paying its own round trip.
52
+
53
+ What the game measured is the reason for the number. Every request costs
54
+ about six hundred and thirty milliseconds before any bytes move, and the
55
+ lane carries roughly 0.79 MB/s. So a three hundred kilobyte file spends
56
+ sixty percent of its time on overhead and a seven megabyte file spends
57
+ seven. The line belongs where the overhead stops dominating, which is a few
58
+ megabytes and not a few kilobytes.
59
+
60
+ Four megabytes was the first attempt and it overshot. Measured on the same
61
+ game twice: four hundred kilobyte files went from 1.80 to 3.24 MB/s when
62
+ batched, while 1.3 megabyte files went from 4.43 down to 3.88 — because
63
+ three concurrent batches move less than six concurrent requests once a file
64
+ is large enough that transfer, not overhead, dominates. The crossover is
65
+ around a megabyte.
66
+ */
67
+ const BATCH_FILE_LIMIT = 1024 * 1024;
68
+ /** How much one batch may carry, and how many objects it may name. */
69
+ const BATCH_BYTES = 24 * 1024 * 1024;
70
+ const BATCH_COUNT = 256;
25
71
  const PART_SIZE = 32 * 1024 * 1024;
72
+ /* Retrying lives in retry.ts; the downloader needs the same rule. */
26
73
  const MEDIA_TYPES = {
27
74
  ".css": "text/css",
28
75
  ".csv": "text/csv",
@@ -68,6 +115,12 @@ class Uploader {
68
115
  credentials;
69
116
  aborted = false;
70
117
  controller = new AbortController();
118
+ /** Null until the service has been asked; see gzipAllowed. */
119
+ gzipSupported = null;
120
+ /** Null until the service has been asked; see chunkingAllowed. */
121
+ chunkingSupported = null;
122
+ /** Null until the service has been asked; see packingAllowed. */
123
+ packingSupported = null;
71
124
  constructor(credentials) {
72
125
  this.credentials = credentials;
73
126
  }
@@ -79,7 +132,54 @@ class Uploader {
79
132
  if (this.aborted)
80
133
  throw new UploadCancelled();
81
134
  }
135
+ /**
136
+ * One request, repeated while repeating it might help.
137
+ *
138
+ * Everything the uploader sends passes through here, which is the only
139
+ * reason a single change can cover all of it. The alternative — retrying at
140
+ * each call site — is where the previous absence of retries came from:
141
+ * every site could see that its own operation was safe to repeat, and no
142
+ * site was responsible for repeating it.
143
+ */
82
144
  async call(route, init) {
145
+ let wait = retry_js_1.RETRY_FIRST_WAIT_MS;
146
+ for (let attempt = 1;; attempt += 1) {
147
+ // Cancellation beats a pending retry: a cancelled upload has to stop
148
+ // waiting rather than serve out its backoff first.
149
+ this.check();
150
+ try {
151
+ return await this.attempt(route, init);
152
+ }
153
+ catch (error) {
154
+ // Its own cancellation first: `worthRetrying` knows nothing of it.
155
+ if (error instanceof UploadCancelled)
156
+ throw error;
157
+ if (attempt >= retry_js_1.RETRY_ATTEMPTS || !(0, retry_js_1.worthRetrying)(error))
158
+ throw error;
159
+ await this.pause(wait);
160
+ wait *= 2;
161
+ }
162
+ }
163
+ }
164
+ /**
165
+ * Wait, unless the upload is cancelled while waiting.
166
+ *
167
+ * Jittered, because the lanes fail together. Six requests against a gateway
168
+ * that has just gone away all fail within milliseconds of each other, and
169
+ * six retries timed from those failures would arrive together too — which
170
+ * turns one outage into a second one at precisely the wrong moment.
171
+ */
172
+ async pause(ms) {
173
+ try {
174
+ await (0, retry_js_1.pauseFor)(this.controller.signal, ms);
175
+ }
176
+ catch {
177
+ // The signal aborted mid-wait; say so in this side's own terms.
178
+ throw new UploadCancelled();
179
+ }
180
+ }
181
+ /** A single attempt. Everything about repeating it lives in `call`. */
182
+ async attempt(route, init) {
83
183
  const token = await this.credentials.token();
84
184
  if (!token)
85
185
  throw new Error("Sign in again before uploading");
@@ -96,28 +196,72 @@ class Uploader {
96
196
  signal: this.controller.signal,
97
197
  });
98
198
  const text = await response.text();
99
- const body = text ? JSON.parse(text) : {};
100
199
  if (!response.ok) {
101
- const message = body?.error?.message ?? `${route} failed (${response.status})`;
200
+ /*
201
+ Parsed only when it looks like JSON. An edge failure serves an HTML
202
+ page, and parsing that unconditionally raised `Unexpected token '<'`
203
+ — which names the parser rather than the 502 behind it, and sends you
204
+ looking in entirely the wrong place.
205
+ */
206
+ let body = {};
207
+ if (text.trimStart().startsWith("{")) {
208
+ try {
209
+ body = JSON.parse(text);
210
+ }
211
+ catch {
212
+ body = {};
213
+ }
214
+ }
215
+ const message = body.error?.message ?? `${route} failed (${response.status})`;
102
216
  /*
103
217
  The service says what kind of failure this is, and callers need that
104
218
  to say anything useful — "someone else saved first" deserves a
105
- different suggestion from "the disk is full". Carrying the code on
106
- the error keeps them from having to match on wording.
219
+ different suggestion from "the disk is full". Carrying the code on the
220
+ error keeps them from matching on wording, and is what lets
221
+ `worthRetrying` tell a refusal apart from a hiccup.
107
222
  */
108
223
  throw Object.assign(new Error(message), {
109
- code: typeof body?.error?.code === "string" ? body.error.code : "",
224
+ code: typeof body.error?.code === "string" ? body.error.code : "",
110
225
  status: response.status,
111
226
  });
112
227
  }
113
- return body;
228
+ if (!text)
229
+ return {};
230
+ try {
231
+ return JSON.parse(text);
232
+ }
233
+ catch {
234
+ /*
235
+ A 200 carrying something that is not JSON is not an answer — it is
236
+ almost always an edge page served in place of the service. Thrown
237
+ without a status so it is treated as a connection that produced
238
+ nothing, which is what it is.
239
+ */
240
+ throw new Error(`${route} returned a malformed reply`);
241
+ }
114
242
  }
115
243
  async run(request, report) {
116
244
  // A version is a snapshot, not a delta, so it has to name every file in
117
245
  // the project — not merely the ones being sent this time. Anything
118
246
  // unchanged keeps the object the previous version already pointed at.
119
247
  const rules = await (0, worktree_js_1.readRules)(request.localPath);
120
- const everything = await (0, worktree_js_1.changedFiles)(request.localPath, rules, null);
248
+ /*
249
+ Surveyed, not listed.
250
+
251
+ `changedFiles` stops after twenty thousand rows, which is right for
252
+ something a person scrolls through and was also deciding what got
253
+ uploaded — so a project past that many files was silently backed up in
254
+ part, with nothing anywhere saying the rest existed. The survey has no
255
+ cap and reads nothing but sizes, which is what makes running it over
256
+ everything affordable.
257
+
258
+ `onDiskNow` in particular has to be complete: it is what tells a file
259
+ that is still here from one this workspace deliberately removed, and a
260
+ truncated version of it would propose deleting everything the walk did
261
+ not reach.
262
+ */
263
+ const everything = await (0, profile_js_1.timed)("survey the project", () => (0, worktree_js_1.surveyFiles)(request.localPath, rules));
264
+ (0, profile_js_1.counted)("files surveyed", everything.length);
121
265
  const ticked = new Set(request.include);
122
266
  if (!ticked.size)
123
267
  throw new Error("Nothing is selected to upload");
@@ -194,11 +338,32 @@ class Uploader {
194
338
  percent: Math.round((index / sending.length) * 20),
195
339
  bytesPerSecond: 0,
196
340
  });
341
+ /*
342
+ Hashing is the first thing that opens the file, and opening is what
343
+ fails on a file another program holds — a database in use, a browser
344
+ profile's LOCK, an antivirus mid-scan. On Windows those still answer
345
+ stat() perfectly well, so the check three lines above lets them
346
+ through and the read below was the one that actually broke.
347
+
348
+ It was unguarded, so the person got a raw "EBUSY: resource busy or
349
+ locked" naming a path they never chose to think about, instead of the
350
+ sentence this code already writes for exactly this situation. The
351
+ outcome was always going to be a refusal; what was missing was it
352
+ being legible.
353
+ */
354
+ let digest;
355
+ try {
356
+ digest = await (0, profile_js_1.timed)("hash files", () => digestOf(full));
357
+ }
358
+ catch {
359
+ vanished.push(file.path);
360
+ continue;
361
+ }
197
362
  declarations.push({
198
363
  path: file.path,
199
364
  full,
200
365
  size,
201
- sha256: await digestOf(full),
366
+ sha256: digest,
202
367
  mediaType: mediaTypeOf(file.path),
203
368
  });
204
369
  totalBytes += size;
@@ -237,70 +402,430 @@ class Uploader {
237
402
  repositoryId = existing;
238
403
  }
239
404
  }
240
- // 3. Send the objects.
405
+ /*
406
+ Sent in sections, not in one attempt.
407
+
408
+ A section is a bounded amount of work — a few hundred megabytes, or a few
409
+ thousand files, whichever comes first — and each one finishes before the
410
+ next begins. A small project is a single section and behaves exactly as
411
+ it did; a thirty gigabyte one is many, and holds no more at a time than
412
+ the small one does.
413
+
414
+ The sections are planned from the survey rather than discovered as the
415
+ upload goes, so the size and shape of the work are known before a byte
416
+ moves, and a resumed upload plans the same sections in the same order.
417
+ */
418
+ const allDeclarations = declarations;
419
+ const declarationByPath = new Map(allDeclarations.map((one) => [one.path, one]));
420
+ const sections = (0, staging_js_1.planSections)(allDeclarations.map((one) => ({ path: one.path, size: one.size })));
421
+ (0, profile_js_1.counted)("sections planned", sections.length);
241
422
  const uploaded = [];
242
423
  let sentBytes = 0;
424
+ /*
425
+ Bytes that actually travelled, which is not the same as bytes accounted
426
+ for: a file the service already held advances the progress bar and costs
427
+ nothing. Reporting the rate from the second number makes a fast, mostly
428
+ reused save look like a stalled one.
429
+ */
430
+ let transferred = 0;
243
431
  /*
244
432
  Files whose content the service already had. Counted apart from what was
245
433
  sent, because reporting them as sent would overstate the work and hide
246
434
  the thing worth knowing: an interrupted save costs nothing to finish.
247
435
  */
248
436
  let alreadyOnAccount = 0;
249
- const startedAt = Date.now();
437
+ /*
438
+ How far each lane is through the file it currently holds.
439
+
440
+ With one worker, "everything finished plus how far I am" was simply
441
+ true. With six it is not: each lane added its own offset to the same
442
+ shared total, so the reported figure jumped between six different
443
+ answers and the progress bar ran backwards. Keeping the offsets apart
444
+ and summing them makes the total the sum of real work again.
445
+ */
446
+ const inFlight = new Map();
447
+ const progressBytes = () => {
448
+ let total = sentBytes;
449
+ for (const offset of inFlight.values())
450
+ total += offset;
451
+ return total;
452
+ };
453
+ /*
454
+ How fast the thing on screen is moving.
455
+
456
+ Two faults, and they compounded. The rate was `transferred` over the
457
+ whole elapsed time, while the bar beside it showed `progressBytes()` —
458
+ different counters measuring different things. `transferred` only moves
459
+ when a file *finishes*, so a single large file reported a rate of
460
+ roughly nothing for its entire transfer and then jumped at the end: one
461
+ real upload sat at "152 MB of 1.1 GB · 10 B/s", which is not a slow
462
+ upload, it is a number describing something else.
463
+
464
+ And dividing by the time since the start made it a lifetime average, so
465
+ a slow first minute went on dragging the figure down long after the
466
+ link had recovered — the opposite of what somebody watching it wants to
467
+ know, which is whether it is moving *now*.
468
+
469
+ So: the same counter the bar uses, over a short trailing window.
470
+ */
471
+ const RATE_WINDOW_MS = 5000;
472
+ const samples = [];
250
473
  const rate = () => {
251
- const seconds = (Date.now() - startedAt) / 1000;
252
- return seconds > 0.2 ? Math.round(sentBytes / seconds) : 0;
474
+ const now = Date.now();
475
+ const bytes = progressBytes();
476
+ const last = samples[samples.length - 1];
477
+ // Sampled rather than recorded per call: this is asked once per report,
478
+ // and reports arrive per file, which for small files is thousands a
479
+ // second.
480
+ if (!last || now - last.at >= 250)
481
+ samples.push({ at: now, bytes });
482
+ while (samples.length > 2 && now - samples[0].at > RATE_WINDOW_MS) {
483
+ samples.shift();
484
+ }
485
+ const oldest = samples[0];
486
+ const seconds = (now - oldest.at) / 1000;
487
+ /*
488
+ Nothing until there is something to divide. Answering zero keeps the
489
+ window quiet rather than showing a figure computed from a fraction of
490
+ a second, which reads as a stall on an upload that has barely begun.
491
+ */
492
+ if (seconds < 0.5)
493
+ return 0;
494
+ return Math.max(0, Math.round((bytes - oldest.bytes) / seconds));
253
495
  };
254
- for (const [index, declaration] of declarations.entries()) {
255
- this.check();
496
+ /*
497
+ Two pipelines, not one queue.
498
+
499
+ The sections were run one after another, and they do not compete for the
500
+ same thing: a section of small files spends about three seconds per batch
501
+ waiting on the service — measured, and independent of how many objects
502
+ the batch holds — while a section of large files is limited by the link.
503
+ One leaves bandwidth idle; the other cannot use any more.
504
+
505
+ So they run together. Each pipeline takes the next section of its own
506
+ kind, and when one runs out it stops rather than stealing from the other,
507
+ because that would put two bandwidth-bound pipelines against a link that
508
+ is already saturated by one.
509
+ */
510
+ /*
511
+ Classified by where the bytes are, not by whether every file qualifies.
512
+
513
+ Requiring every file to be small put one large file in charge of a whole
514
+ section: on a four gigabyte game it left three sections in one pipeline
515
+ and eighteen in the other, which is not two pipelines. Judging by the
516
+ share of bytes that will be batched splits the same game nine and twelve.
517
+ */
518
+ const smallShare = (one) => one.files
519
+ .filter((file) => file.size <= BATCH_FILE_LIMIT)
520
+ .reduce((sum, file) => sum + file.size, 0) / Math.max(one.bytes, 1);
521
+ const batchy = sections.filter((one) => smallShare(one) >= 0.5);
522
+ const heavy = sections.filter((one) => smallShare(one) < 0.5);
523
+ (0, profile_js_1.counted)("sections batched", batchy.length);
524
+ (0, profile_js_1.counted)("sections heavy", heavy.length);
525
+ const runSection = async (section) => {
526
+ const declarations = section.files.map((file) => declarationByPath.get(file.path));
256
527
  report({
257
528
  stage: "upload",
258
- files: index,
259
- totalFiles: declarations.length,
260
- bytes: sentBytes,
529
+ files: 0,
530
+ totalFiles: allDeclarations.length,
531
+ bytes: progressBytes(),
261
532
  totalBytes,
262
- path: declaration.path,
263
- percent: 20 + Math.round((sentBytes / Math.max(totalBytes, 1)) * 72),
533
+ path: `Section ${section.index + 1} of ${sections.length}`,
534
+ percent: 20 + Math.round((progressBytes() / Math.max(totalBytes, 1)) * 72),
264
535
  bytesPerSecond: rate(),
265
536
  });
266
537
  /*
267
- Content the service already holds does not need sending. Asking costs
268
- one round trip and saves the whole file — which after an interrupted
269
- save is the difference between re-uploading a project and re-uploading
270
- nothing.
538
+ Ask once what the account already holds.
539
+
540
+ This used to be one request per file, immediately before deciding
541
+ whether to send it — nine thousand files meant nine thousand serial
542
+ round trips before a single byte moved, and on a folder of that size the
543
+ asking took longer than the transfer. The answer per digest is a boolean
544
+ and an identifier, so the whole question fits in one request.
545
+ */
546
+ const alreadyHeld = await (0, profile_js_1.timed)("ask what is already held", () => this.storedInBulk(repositoryId, declarations.map((one) => one.sha256)));
547
+ /*
548
+ The small files go first, packed together.
549
+
550
+ They are the overwhelming majority by count and almost nothing by size,
551
+ so sending them one per request is where an upload spends its time. What
552
+ is left after this — the large files — is where bandwidth actually
553
+ matters, and those still go one per request through the lanes below.
554
+ */
555
+ const missing = declarations.filter((one) => !alreadyHeld.has(one.sha256));
556
+ const smallOnes = missing.filter((one) => one.size <= BATCH_FILE_LIMIT);
557
+ // 3. Send the objects.
558
+ /*
559
+ What was sent, in whichever shape it was sent. A small file is one
560
+ object; a large one is an ordered list of chunks and the digest of the
561
+ whole, which is what gives it an identity when no single object holds it.
271
562
  */
272
- const alreadyStored = await this.findStored(repositoryId, declaration);
273
- if (alreadyStored)
274
- alreadyOnAccount += 1;
275
- const result = alreadyStored ??
276
- (declaration.size <= DIRECT_LIMIT
277
- ? await this.putDirect(repositoryId, declaration).catch((error) => {
278
- // Naming the file turns "it failed" into something actionable.
279
- const reason = error instanceof Error ? error.message : String(error);
280
- throw new Error(`${declaration.path}: ${reason}`);
281
- })
282
- : await this.putMultipart(repositoryId, declaration, (offset) => {
563
+ const batched = new Map();
564
+ /*
565
+ Files that went up inside a solid pack. Kept apart from `batched` because
566
+ they are named differently in the version: a slice of a pack rather than
567
+ an object of their own.
568
+ */
569
+ const packedInto = new Map();
570
+ if (smallOnes.length > 1) {
571
+ let pack = [];
572
+ let packBytes = 0;
573
+ /*
574
+ Batches overlap rather than queue.
575
+
576
+ Each one was sent and awaited before the next was built, so the link
577
+ sat idle while the client read and packed the following batch, and
578
+ again while the service stored it. Measured on four hundred kilobyte
579
+ files, one batch at a time managed 2.52 MB/s and four at once 3.26 —
580
+ against a line that can do 5.58.
581
+
582
+ Bounded low: a batch holds its whole body in memory on both ends, so
583
+ three in flight of up to twenty four megabytes is already the most
584
+ that is reasonable to have outstanding.
585
+ */
586
+ const BATCH_LANES = 3;
587
+ const flying = new Set();
588
+ const flush = async () => {
589
+ if (!pack.length)
590
+ return;
591
+ const taking = pack;
592
+ pack = [];
593
+ packBytes = 0;
594
+ const task = (async () => {
595
+ try {
596
+ /*
597
+ Compressed together first. This is what the .cbx format is for and
598
+ what the upload path was not using: measured on a real project,
599
+ small files came to 42.9% of their size compressed one at a time
600
+ and 30.2% compressed together, because a two-kilobyte file gives a
601
+ compressor no dictionary worth having.
602
+ */
603
+ const placed = (await this.packingAllowed())
604
+ ? await (0, profile_js_1.timed)("send packs", () => this.putPack(repositoryId, taking.map((item) => ({
605
+ declaration: item.declaration,
606
+ body: item.body,
607
+ }))))
608
+ : null;
609
+ if (placed) {
610
+ (0, profile_js_1.counted)("files sent in packs", taking.length);
611
+ for (const [digest, one] of placed)
612
+ packedInto.set(digest, one);
613
+ }
614
+ else {
615
+ /*
616
+ Not worth packing — already-compressed content, or too few files.
617
+ They still travel together, just as separate objects.
618
+ */
619
+ const answer = await (0, profile_js_1.timed)("send batches", () => this.putBatch(repositoryId, taking));
620
+ (0, profile_js_1.counted)("files sent in batches", taking.length);
621
+ for (const [digest, one] of answer)
622
+ batched.set(digest, one);
623
+ }
624
+ }
625
+ catch {
626
+ /*
627
+ A refusal here is never fatal: everything in the pack is simply
628
+ left for the ordinary path below, which sends it one at a time.
629
+ An older deployment without this route lands here every time.
630
+ */
631
+ }
632
+ /*
633
+ Only what actually landed. Counting the whole pack meant a batch the
634
+ service refused was counted here and then sent again by the lanes
635
+ below and counted a second time — which is how the progress bar came
636
+ to report a hundred and twenty percent of a forty megabyte upload.
637
+ */
638
+ for (const item of taking) {
639
+ const digest = item.declaration.sha256;
640
+ if (!batched.has(digest) && !packedInto.has(digest))
641
+ continue;
642
+ sentBytes += item.declaration.size;
643
+ transferred += item.encoded.body.byteLength;
644
+ }
283
645
  report({
284
646
  stage: "upload",
285
- files: index,
647
+ files: 0,
286
648
  totalFiles: declarations.length,
287
- bytes: sentBytes + offset,
649
+ bytes: progressBytes(),
288
650
  totalBytes,
289
- path: declaration.path,
290
- percent: 20 +
291
- Math.round(((sentBytes + offset) / Math.max(totalBytes, 1)) * 72),
651
+ path: taking[taking.length - 1].declaration.path,
652
+ percent: 20 + Math.round((progressBytes() / Math.max(totalBytes, 1)) * 72),
292
653
  bytesPerSecond: rate(),
293
654
  });
294
- }));
295
- uploaded.push({
296
- path: declaration.path,
297
- objectId: result.objectId,
298
- sourceSize: declaration.size,
299
- storedSize: result.size,
300
- mediaType: declaration.mediaType,
301
- });
302
- sentBytes += declaration.size;
303
- }
655
+ })().finally(() => flying.delete(task));
656
+ flying.add(task);
657
+ if (flying.size >= BATCH_LANES)
658
+ await Promise.race(flying);
659
+ };
660
+ for (const declaration of smallOnes) {
661
+ this.check();
662
+ let body;
663
+ try {
664
+ body = await (0, profile_js_1.timed)("read small files", async () => new Uint8Array(await (0, promises_1.readFile)(declaration.full)));
665
+ }
666
+ catch {
667
+ /* Unreadable now; the ordinary path reports it properly. */
668
+ continue;
669
+ }
670
+ const allowGzip = await this.gzipAllowed();
671
+ const encoded = await (0, profile_js_1.timed)("compress small files", () => (0, compress_js_1.encodeForUpload)(body, declaration.path, allowGzip));
672
+ if (pack.length >= BATCH_COUNT ||
673
+ packBytes + encoded.body.byteLength > BATCH_BYTES) {
674
+ await flush();
675
+ }
676
+ pack.push({ declaration, body, encoded });
677
+ packBytes += encoded.body.byteLength;
678
+ }
679
+ await flush();
680
+ await Promise.all(flying);
681
+ }
682
+ /*
683
+ Several files at once, rather than one at a time.
684
+
685
+ Every file costs at least one round trip, and they were taken strictly
686
+ in order — so the transfer ran at one file per latency, no matter how
687
+ much bandwidth was free. On a folder of thousands of small files that is
688
+ the whole cost of the upload: the network is idle between each one.
689
+
690
+ Bounded, because the opposite mistake is worse. Thousands of concurrent
691
+ requests would exhaust sockets, defeat the service's own rate limits and
692
+ make a failure impossible to attribute.
693
+ */
694
+ const queue = declarations.entries();
695
+ const runWorker = async () => {
696
+ for (const [index, declaration] of queue) {
697
+ this.check();
698
+ report({
699
+ stage: "upload",
700
+ files: index,
701
+ totalFiles: declarations.length,
702
+ bytes: sentBytes,
703
+ totalBytes,
704
+ path: declaration.path,
705
+ percent: 20 + Math.round((sentBytes / Math.max(totalBytes, 1)) * 72),
706
+ bytesPerSecond: rate(),
707
+ });
708
+ /*
709
+ Content the service already holds does not need sending. Asking costs
710
+ one round trip and saves the whole file — which after an interrupted
711
+ save is the difference between re-uploading a project and re-uploading
712
+ nothing.
713
+ */
714
+ /*
715
+ A large file goes up in pieces, cut where its own content says.
716
+
717
+ This is what CBX is for and what it was never doing on the wire. The
718
+ boundaries come from the same chunker the .cbx bundle uses, so an
719
+ edit in the middle of a large file leaves the chunks either side of it
720
+ untouched — and an untouched chunk is one the service already holds,
721
+ which costs a question rather than a transfer. A small file is left
722
+ whole: cutting it up would trade one request for several and save
723
+ nothing.
724
+ */
725
+ if (declaration.size > CHUNK_THRESHOLD && (await this.chunkingAllowed())) {
726
+ const pieces = await this.putChunked(repositoryId, declaration, (offset) => {
727
+ inFlight.set(index, offset);
728
+ report({
729
+ stage: "upload",
730
+ files: index,
731
+ totalFiles: declarations.length,
732
+ bytes: progressBytes(),
733
+ totalBytes,
734
+ path: declaration.path,
735
+ percent: 20 +
736
+ Math.round(((sentBytes + offset) / Math.max(totalBytes, 1)) * 72),
737
+ bytesPerSecond: rate(),
738
+ });
739
+ });
740
+ inFlight.delete(index);
741
+ alreadyOnAccount += pieces.reused;
742
+ uploaded.push({
743
+ path: declaration.path,
744
+ sha256: declaration.sha256,
745
+ chunks: pieces.chunks,
746
+ sourceSize: declaration.size,
747
+ storedSize: pieces.chunks.reduce((sum, one) => sum + one.storedSize, 0),
748
+ mediaType: declaration.mediaType,
749
+ });
750
+ sentBytes += declaration.size;
751
+ transferred += pieces.sentBytes;
752
+ continue;
753
+ }
754
+ const fromPack = packedInto.get(declaration.sha256);
755
+ if (fromPack) {
756
+ uploaded.push({
757
+ path: declaration.path,
758
+ sha256: declaration.sha256,
759
+ pack: fromPack,
760
+ sourceSize: declaration.size,
761
+ /* Its share of the pack, so the version's totals stay honest. */
762
+ storedSize: fromPack.storedSize,
763
+ mediaType: declaration.mediaType,
764
+ });
765
+ continue;
766
+ }
767
+ const fromBatch = batched.get(declaration.sha256);
768
+ if (fromBatch) {
769
+ uploaded.push({
770
+ path: declaration.path,
771
+ objectId: fromBatch.objectId,
772
+ sourceSize: declaration.size,
773
+ storedSize: fromBatch.size,
774
+ mediaType: declaration.mediaType,
775
+ });
776
+ continue;
777
+ }
778
+ const alreadyStored = alreadyHeld.get(declaration.sha256) ??
779
+ /*
780
+ Only asked individually when the batch did not cover it — a digest
781
+ that changed after the bulk question, because the file was rewritten
782
+ while this upload was running.
783
+ */
784
+ (await this.findStored(repositoryId, declaration));
785
+ if (alreadyStored)
786
+ alreadyOnAccount += 1;
787
+ const result = alreadyStored ??
788
+ (declaration.size <= DIRECT_LIMIT
789
+ ? await (0, profile_js_1.timed)("send files one at a time", () => this.putDirect(repositoryId, declaration)).catch((error) => {
790
+ // Naming the file turns "it failed" into something actionable.
791
+ const reason = error instanceof Error ? error.message : String(error);
792
+ throw new Error(`${declaration.path}: ${reason}`);
793
+ })
794
+ : await this.putMultipart(repositoryId, declaration, (offset) => {
795
+ report({
796
+ stage: "upload",
797
+ files: index,
798
+ totalFiles: declarations.length,
799
+ bytes: progressBytes(),
800
+ totalBytes,
801
+ path: declaration.path,
802
+ percent: 20 +
803
+ Math.round(((sentBytes + offset) / Math.max(totalBytes, 1)) * 72),
804
+ bytesPerSecond: rate(),
805
+ });
806
+ }));
807
+ inFlight.delete(index);
808
+ uploaded.push({
809
+ path: declaration.path,
810
+ objectId: result.objectId,
811
+ sourceSize: declaration.size,
812
+ storedSize: result.size,
813
+ mediaType: declaration.mediaType,
814
+ });
815
+ sentBytes += declaration.size;
816
+ if (!alreadyStored)
817
+ transferred += declaration.size;
818
+ }
819
+ };
820
+ await Promise.all(Array.from({ length: Math.min(UPLOAD_LANES, declarations.length) }, () => runWorker()));
821
+ };
822
+ const pipeline = async (queue) => {
823
+ for (const section of queue) {
824
+ this.check();
825
+ await runSection(section);
826
+ }
827
+ };
828
+ await Promise.all([pipeline(batchy), pipeline(heavy)]);
304
829
  // 4. Name the version, which is what makes the upload visible.
305
830
  this.check();
306
831
  report({
@@ -319,9 +844,38 @@ class Uploader {
319
844
  ...uploaded,
320
845
  ...reused.map((file) => {
321
846
  const held = prior.get(file.path);
847
+ /*
848
+ Republished in the shape it was already stored in. A file kept in
849
+ pieces names the same pieces; flattening it to one object here would
850
+ name an object that was never created.
851
+ */
322
852
  return {
323
853
  path: file.path,
324
- objectId: held.objectId,
854
+ /*
855
+ Republished in the shape it is stored in — all three of them. The
856
+ packed case was missing, so an unchanged small file was declared
857
+ with an empty object id instead of the slice it actually is.
858
+ */
859
+ ...(held.pack
860
+ ? {
861
+ /*
862
+ The uploader's own shape for a pack slice, which is what
863
+ the publish step converts to the wire. A file that came
864
+ back from the service is described the same way as one this
865
+ run packed, so the two paths converge before publishing
866
+ rather than at it.
867
+ */
868
+ pack: {
869
+ packObjectId: held.pack.objectId,
870
+ offset: held.pack.offset,
871
+ length: held.pack.length,
872
+ storedSize: held.storedSize,
873
+ },
874
+ sha256: held.sha256,
875
+ }
876
+ : held.chunks?.length
877
+ ? { chunks: held.chunks, sha256: held.sha256 }
878
+ : { objectId: held.objectId }),
325
879
  sourceSize: held.sourceSize,
326
880
  storedSize: held.storedSize,
327
881
  mediaType: held.mediaType,
@@ -339,8 +893,18 @@ class Uploader {
339
893
  if (dropped.length) {
340
894
  const shown = dropped.slice(0, 5).join(", ");
341
895
  const rest = dropped.length > 5 ? ` and ${dropped.length - 5} more` : "";
896
+ /*
897
+ Say what to do about it. Refusing is right — a version quietly missing
898
+ a file somebody chose is the one failure a backup tool must never have
899
+ — but the message stopped at the refusal, and the person is left with
900
+ a list of paths and no idea that the fix is either closing whatever
901
+ holds them or unticking them. Almost every case is a file another
902
+ program has open, so that is what it says.
903
+ */
342
904
  throw new Error(`${dropped.length} selected file${dropped.length === 1 ? "" : "s"} could not be read, ` +
343
- `so nothing was saved: ${shown}${rest}. Nothing was changed on your account.`);
905
+ `so nothing was saved: ${shown}${rest}. This usually means another program ` +
906
+ `has them open. Close it and try again, or untick them in the file list. ` +
907
+ `Nothing was changed on your account.`);
344
908
  }
345
909
  const sourceBytes = contents.reduce((total, item) => total + item.sourceSize, 0);
346
910
  const storedBytes = contents.reduce((total, item) => total + item.storedSize, 0);
@@ -358,6 +922,7 @@ class Uploader {
358
922
  ...(request.expectedHeadVersionId === undefined
359
923
  ? {}
360
924
  : { expectedHeadVersionId: request.expectedHeadVersionId }),
925
+ ...(request.track ? { track: request.track } : {}),
361
926
  /*
362
927
  Names this attempt so a retry after a lost connection is answered
363
928
  with the version already made, rather than making a second one.
@@ -366,13 +931,36 @@ class Uploader {
366
931
  repositoryId,
367
932
  expectedHeadVersionId: request.expectedHeadVersionId,
368
933
  message: request.message,
369
- files: contents,
934
+ /*
935
+ Named by what each file is. A whole file is its object; a chunked
936
+ one is the digest of its contents — so retrying the same publish
937
+ produces the same key either way, which is the point of it.
938
+ */
939
+ files: contents.map((item) => ({
940
+ path: item.path,
941
+ objectId: item.objectId ?? item.sha256 ?? "",
942
+ })),
370
943
  }),
371
944
  sourceSize: sourceBytes,
372
945
  storedSize: storedBytes,
946
+ /*
947
+ One shape or the other, never both — the service refuses a file that
948
+ names an object and a chunk list, and so does its database.
949
+ */
373
950
  files: contents.map((item) => ({
374
951
  path: item.path,
375
- objectId: item.objectId,
952
+ ...(item.pack
953
+ ? {
954
+ pack: {
955
+ objectId: item.pack.packObjectId,
956
+ offset: item.pack.offset,
957
+ length: item.pack.length,
958
+ },
959
+ sha256: item.sha256,
960
+ }
961
+ : item.chunks
962
+ ? { chunks: item.chunks, sha256: item.sha256 }
963
+ : { objectId: item.objectId }),
376
964
  sourceSize: item.sourceSize,
377
965
  storedSize: item.storedSize,
378
966
  mediaType: item.mediaType,
@@ -439,8 +1027,26 @@ class Uploader {
439
1027
  method: "GET",
440
1028
  });
441
1029
  for (const row of body.files ?? []) {
1030
+ const pieces = Array.isArray(row.chunks)
1031
+ ? row.chunks.map((chunk) => ({
1032
+ objectId: String(chunk.objectId ?? ""),
1033
+ sourceSize: Number(chunk.sourceSize ?? 0),
1034
+ storedSize: Number(chunk.storedSize ?? 0),
1035
+ }))
1036
+ : undefined;
1037
+ const packed = row.pack;
442
1038
  held.set(String(row.path ?? ""), {
443
1039
  objectId: String(row.objectId ?? ""),
1040
+ ...(packed?.objectId
1041
+ ? {
1042
+ pack: {
1043
+ objectId: String(packed.objectId),
1044
+ offset: Number(packed.offset ?? 0),
1045
+ length: Number(packed.length ?? 0),
1046
+ },
1047
+ }
1048
+ : {}),
1049
+ ...(pieces?.length ? { chunks: pieces } : {}),
444
1050
  sha256: String(row.sha256 ?? ""),
445
1051
  sourceSize: Number(row.sourceSize ?? 0),
446
1052
  storedSize: Number(row.storedSize ?? 0),
@@ -498,6 +1104,298 @@ class Uploader {
498
1104
  return null;
499
1105
  }
500
1106
  }
1107
+ /**
1108
+ * Send many small files compressed together as one pack.
1109
+ *
1110
+ * Returns where each file ended up inside the stored pack, or null if the
1111
+ * service would not take it — in which case the caller falls back to sending
1112
+ * them separately, which is slower and larger but always works.
1113
+ */
1114
+ async putPack(repositoryId, items) {
1115
+ const built = await (0, solid_js_1.buildSolidPack)(items.map((item) => ({
1116
+ sha256: item.declaration.sha256,
1117
+ body: item.body,
1118
+ })));
1119
+ if (!built)
1120
+ return null;
1121
+ const manifest = JSON.stringify({
1122
+ sha256: built.sha256,
1123
+ storedSha256: built.storedSha256,
1124
+ size: built.size,
1125
+ members: built.members,
1126
+ });
1127
+ const manifestBytes = new TextEncoder().encode(manifest);
1128
+ const packed = new Uint8Array(4 + manifestBytes.byteLength + built.body.byteLength);
1129
+ new DataView(packed.buffer).setUint32(0, manifestBytes.byteLength, false);
1130
+ packed.set(manifestBytes, 4);
1131
+ packed.set(built.body, 4 + manifestBytes.byteLength);
1132
+ const answer = await this.call(`/v1/repositories/${repositoryId}/objects/pack`, {
1133
+ method: "POST",
1134
+ contentType: "application/octet-stream",
1135
+ body: packed,
1136
+ });
1137
+ const placed = new Map();
1138
+ /*
1139
+ Each file's share of what the pack actually costs.
1140
+
1141
+ A packed file has no stored size of its own — the pack was charged once
1142
+ for all of them — but a version still has to say what it costs, and
1143
+ reporting each file's uncompressed length made a pack that halved the
1144
+ storage look like it had saved nothing. Apportioned by length, so the
1145
+ shares add up to the pack and the totals stay true.
1146
+ */
1147
+ const compressed = Number(answer.storedSize) || built.body.byteLength;
1148
+ for (const member of built.members) {
1149
+ placed.set(member.sha256, {
1150
+ packObjectId: answer.packObjectId,
1151
+ offset: member.offset,
1152
+ length: member.length,
1153
+ storedSize: built.size
1154
+ ? Math.round((member.length / built.size) * compressed)
1155
+ : 0,
1156
+ });
1157
+ }
1158
+ return placed;
1159
+ }
1160
+ /**
1161
+ * Send many small objects in one request.
1162
+ *
1163
+ * Returns what landed, keyed by digest. Anything the service could not take
1164
+ * is simply absent, and the caller sends those the ordinary way — these are
1165
+ * content-addressed, so a retry is free and a partial batch costs nothing
1166
+ * but the objects it missed.
1167
+ */
1168
+ async putBatch(repositoryId, items) {
1169
+ const landed = new Map();
1170
+ if (!items.length)
1171
+ return landed;
1172
+ const manifest = JSON.stringify({
1173
+ objects: items.map((item) => ({
1174
+ sha256: item.declaration.sha256,
1175
+ size: item.body.byteLength,
1176
+ storedSize: item.encoded.body.byteLength,
1177
+ mediaType: item.declaration.mediaType,
1178
+ encoding: item.encoded.encoding,
1179
+ ...(item.encoded.encoding === "gzip"
1180
+ ? { storedSha256: item.encoded.storedSha256 }
1181
+ : {}),
1182
+ })),
1183
+ });
1184
+ const manifestBytes = new TextEncoder().encode(manifest);
1185
+ const total = 4 +
1186
+ manifestBytes.byteLength +
1187
+ items.reduce((sum, item) => sum + item.encoded.body.byteLength, 0);
1188
+ const packed = new Uint8Array(total);
1189
+ new DataView(packed.buffer).setUint32(0, manifestBytes.byteLength, false);
1190
+ packed.set(manifestBytes, 4);
1191
+ let at = 4 + manifestBytes.byteLength;
1192
+ for (const item of items) {
1193
+ packed.set(item.encoded.body, at);
1194
+ at += item.encoded.body.byteLength;
1195
+ }
1196
+ const answer = await this.call(`/v1/repositories/${repositoryId}/objects/batch`, {
1197
+ method: "POST",
1198
+ contentType: "application/octet-stream",
1199
+ body: packed,
1200
+ });
1201
+ for (const one of answer.objects ?? []) {
1202
+ landed.set(one.sha256, {
1203
+ objectId: one.objectId,
1204
+ size: one.storedSize,
1205
+ });
1206
+ }
1207
+ return landed;
1208
+ }
1209
+ /**
1210
+ * Which of these digests the account already holds, in one request.
1211
+ *
1212
+ * Falls back to an empty answer rather than an error: an older deployment
1213
+ * has no such route, and the per-file lookup below still works, so the
1214
+ * upload is slower and never broken.
1215
+ */
1216
+ async storedInBulk(repositoryId, digests) {
1217
+ const held = new Map();
1218
+ if (!digests.length)
1219
+ return held;
1220
+ try {
1221
+ /*
1222
+ Split so one request stays a reasonable size on both ends. Five
1223
+ thousand digests is roughly a third of a megabyte of JSON, which is
1224
+ nothing beside the transfer it is replacing.
1225
+ */
1226
+ for (let at = 0; at < digests.length; at += 5000) {
1227
+ this.check();
1228
+ const batch = [...new Set(digests.slice(at, at + 5000))];
1229
+ const answer = await this.call(`/v1/repositories/${repositoryId}/stored`, {
1230
+ method: "POST",
1231
+ contentType: "application/json",
1232
+ body: JSON.stringify({ sha256: batch }),
1233
+ });
1234
+ for (const [digest, one] of Object.entries(answer.stored ?? {})) {
1235
+ held.set(digest, { objectId: one.objectId, size: one.storedSize });
1236
+ }
1237
+ }
1238
+ }
1239
+ catch {
1240
+ /*
1241
+ Unknown route, or a refusal. Answering "nothing is held" is always
1242
+ safe: every file is then asked about individually, exactly as before.
1243
+ */
1244
+ return new Map();
1245
+ }
1246
+ return held;
1247
+ }
1248
+ /**
1249
+ * Send one file as content-defined chunks, reusing whatever is already held.
1250
+ *
1251
+ * The boundaries come from CBX's chunker, so they depend on the bytes rather
1252
+ * than on offsets: inserting a line near the top of a large file shifts one
1253
+ * chunk instead of every chunk after it. Each piece is an ordinary
1254
+ * content-addressed object, which is what makes "the service already has
1255
+ * this one" a question the existing protocol can already answer.
1256
+ */
1257
+ async putChunked(repositoryId, declaration, onOffset) {
1258
+ /*
1259
+ Read in windows, not all at once.
1260
+
1261
+ This used to load the whole file to chunk it, which for the files that
1262
+ reach this path — everything past sixteen megabytes — meant an allocation
1263
+ the size of the file. A three gigabyte video was a three gigabyte buffer
1264
+ and an out-of-memory crash partway through an upload, which is the one
1265
+ failure worse than a slow one: it happens after the work.
1266
+
1267
+ The window holds at most one read plus one maximum chunk, whatever the
1268
+ file is. Exactly how cbx.ts packs a bundle, and for the same reason.
1269
+ */
1270
+ const chunks = [];
1271
+ let reused = 0;
1272
+ let sent = 0;
1273
+ let done = 0;
1274
+ const note = (kind, bytes) => {
1275
+ if (kind === "reused")
1276
+ reused += 1;
1277
+ else
1278
+ sent += bytes;
1279
+ };
1280
+ /*
1281
+ A file's chunks go up several at a time.
1282
+
1283
+ They were sent strictly one after another, which meant one large file
1284
+ used exactly one lane however many were free. Measured on a gigabyte
1285
+ folder, throughput fell from 4 MB/s to 0.97 MB/s as the small files ran
1286
+ out and only the big one was left — five lanes idle while one crawled.
1287
+ For a folder that is *one* large file, that was the whole upload.
1288
+
1289
+ Bounded tighter than the file lanes because each one holds a piece in
1290
+ memory: four in flight of at most a maximum chunk each, rather than six.
1291
+ */
1292
+ const CHUNK_LANES = 4;
1293
+ const active = new Set();
1294
+ const dispatch = async (piece, at) => {
1295
+ const task = this.sendChunk(repositoryId, declaration, piece, chunks, at, note).finally(() => {
1296
+ active.delete(task);
1297
+ done += piece.length;
1298
+ onOffset(done);
1299
+ });
1300
+ active.add(task);
1301
+ if (active.size >= CHUNK_LANES)
1302
+ await Promise.race(active);
1303
+ };
1304
+ let ordinal = 0;
1305
+ let pending = Buffer.alloc(0);
1306
+ for await (const block of (0, node_fs_1.createReadStream)(declaration.full, {
1307
+ highWaterMark: 8 * 1024 * 1024,
1308
+ })) {
1309
+ this.check();
1310
+ const incoming = Buffer.from(block);
1311
+ pending = pending.length ? Buffer.concat([pending, incoming]) : incoming;
1312
+ let from = 0;
1313
+ for (const cut of (0, cbx_js_1.cutPoints)(pending)) {
1314
+ await dispatch(Buffer.from(pending.subarray(from, cut)), ordinal++);
1315
+ from = cut;
1316
+ }
1317
+ pending = Buffer.from(pending.subarray(from));
1318
+ }
1319
+ /*
1320
+ cutPoints returns the boundaries *inside* what it was given and never the
1321
+ end, so whatever is left after the last cut is a chunk it does not
1322
+ mention — the packer in cbx.ts emits that tail itself, after its loop.
1323
+ Dropping it truncates every chunked file by exactly its last piece.
1324
+ */
1325
+ if (pending.length)
1326
+ await dispatch(pending, ordinal++);
1327
+ await Promise.all(active);
1328
+ /*
1329
+ Order is the file. Every position must be filled: a hole would mean a
1330
+ chunk that never landed, and publishing around it would produce a version
1331
+ whose bytes are individually verified and collectively wrong.
1332
+ */
1333
+ const ordered = chunks.slice(0, ordinal);
1334
+ if (ordered.some((one) => !one)) {
1335
+ throw new Error(`${declaration.path}: a chunk did not finish uploading`);
1336
+ }
1337
+ return {
1338
+ chunks: ordered,
1339
+ reused,
1340
+ sentBytes: sent,
1341
+ };
1342
+ }
1343
+ /** Send one chunk, reusing it if the account already holds those bytes. */
1344
+ async sendChunk(repositoryId, declaration, piece,
1345
+ /*
1346
+ Written at a known position rather than appended. The chunks are sent
1347
+ several at a time and finish in whatever order the network decides, but
1348
+ their order *is* the file — appending as they land would reassemble a
1349
+ large file out of sequence, which produces bytes that are all present,
1350
+ all verified individually, and wrong.
1351
+ */
1352
+ chunks, at, note) {
1353
+ {
1354
+ this.check();
1355
+ const digest = (0, node_crypto_1.createHash)("sha256").update(piece).digest("hex");
1356
+ /*
1357
+ Asked per chunk rather than per file. This is the whole saving: the
1358
+ parts of a large file that did not change answer "already held" and
1359
+ never travel.
1360
+ */
1361
+ const held = await this.findStored(repositoryId, {
1362
+ ...declaration,
1363
+ sha256: digest,
1364
+ size: piece.length,
1365
+ });
1366
+ if (held) {
1367
+ note("reused", 0);
1368
+ chunks[at] = {
1369
+ objectId: held.objectId,
1370
+ sourceSize: piece.length,
1371
+ storedSize: held.size,
1372
+ };
1373
+ }
1374
+ else {
1375
+ const encoded = await (0, compress_js_1.encodeForUpload)(new Uint8Array(piece), declaration.path, await this.gzipAllowed());
1376
+ const query = encoded.encoding === "gzip"
1377
+ ? `?kind=chunk&role=chunk&encoding=gzip` +
1378
+ `&logicalSize=${piece.length}` +
1379
+ `&storedSha256=${encoded.storedSha256}`
1380
+ : `?kind=chunk&role=chunk`;
1381
+ const stored = await this.call(`/v1/repositories/${repositoryId}/objects/${digest}${query}`, {
1382
+ method: "PUT",
1383
+ contentType: "application/octet-stream",
1384
+ body: encoded.body,
1385
+ }).catch((error) => {
1386
+ const reason = error instanceof Error ? error.message : String(error);
1387
+ throw new Error(`${declaration.path}: ${reason}`);
1388
+ });
1389
+ note("sent", encoded.body.byteLength);
1390
+ chunks[at] = {
1391
+ objectId: stored.objectId,
1392
+ sourceSize: piece.length,
1393
+ /* What the service keeps, which is smaller when the piece gzipped. */
1394
+ storedSize: stored.storedSize ?? stored.size,
1395
+ };
1396
+ }
1397
+ }
1398
+ }
501
1399
  async putDirect(repositoryId, declaration) {
502
1400
  const body = await (0, promises_1.readFile)(declaration.full);
503
1401
  // The file was hashed earlier, and a live file — a database, a log — can
@@ -508,11 +1406,97 @@ class Uploader {
508
1406
  declaration.sha256 = digest;
509
1407
  declaration.size = body.length;
510
1408
  }
511
- return this.call(`/v1/repositories/${repositoryId}/objects/${digest}?kind=chunk&role=chunk`, {
1409
+ /*
1410
+ The digest in the path stays the original file's, whatever travels.
1411
+
1412
+ That is the whole reason an object can be sent compressed at all: its
1413
+ identity is what it *is*, not how it was packed, so a file the service
1414
+ already holds is recognised as held even if the two clients that sent it
1415
+ made different choices about compression.
1416
+ */
1417
+ const encoded = await (0, compress_js_1.encodeForUpload)(new Uint8Array(body), declaration.path, await this.gzipAllowed());
1418
+ const query = encoded.encoding === "gzip"
1419
+ ? `?kind=chunk&role=chunk&encoding=gzip` +
1420
+ `&logicalSize=${body.length}` +
1421
+ `&storedSha256=${encoded.storedSha256}`
1422
+ : `?kind=chunk&role=chunk`;
1423
+ /*
1424
+ The answer carries both sizes and they are not the same thing: `size` is
1425
+ the file's own length, `storedSize` is what the service actually keeps.
1426
+ They agree only while nothing is compressed, which is why recording the
1427
+ wrong one went unnoticed until a compressed object was sent — and then
1428
+ the version is refused for declaring a size the object does not have.
1429
+ */
1430
+ const stored = await this.call(`/v1/repositories/${repositoryId}/objects/${digest}${query}`, {
512
1431
  method: "PUT",
513
1432
  contentType: declaration.mediaType,
514
- body: new Uint8Array(body),
1433
+ body: encoded.body,
515
1434
  });
1435
+ return {
1436
+ objectId: stored.objectId,
1437
+ size: stored.storedSize ?? stored.size,
1438
+ };
1439
+ }
1440
+ /**
1441
+ * Whether this deployment records gzipped objects.
1442
+ *
1443
+ * Asked once and remembered. Sending an encoding the service cannot record
1444
+ * is refused outright, so this is not an optimisation to guess at — an older
1445
+ * deployment answers without gzip and every upload simply goes as it always
1446
+ * did.
1447
+ */
1448
+ /**
1449
+ * Whether this deployment can store a file as chunks.
1450
+ *
1451
+ * Asked rather than assumed, because the app updates on its own schedule and
1452
+ * the service updates on another: a build that started chunking against a
1453
+ * deployment without the table behind it would fail every large upload, and
1454
+ * fail it after doing all the work. An older service answers no and files go
1455
+ * up whole exactly as they always did.
1456
+ */
1457
+ async packingAllowed() {
1458
+ if (this.packingSupported !== null)
1459
+ return this.packingSupported;
1460
+ this.packingSupported = (await this.serviceFeatures()).includes("solid-packs");
1461
+ return this.packingSupported;
1462
+ }
1463
+ async chunkingAllowed() {
1464
+ if (this.chunkingSupported !== null)
1465
+ return this.chunkingSupported;
1466
+ this.chunkingSupported = (await this.serviceFeatures()).includes("chunked-files");
1467
+ return this.chunkingSupported;
1468
+ }
1469
+ /** What /health says this deployment accepts. Fetched once. */
1470
+ async serviceFeatures() {
1471
+ try {
1472
+ const response = await fetch(`${this.credentials.origin()}/health`, {
1473
+ headers: { accept: "application/json", ...(0, identify_js_1.clientHeaders)() },
1474
+ signal: AbortSignal.timeout(8000),
1475
+ });
1476
+ const body = (await response.json());
1477
+ return body.features ?? [];
1478
+ }
1479
+ catch {
1480
+ /* Unknown is treated as unsupported: never risk a refused upload. */
1481
+ return [];
1482
+ }
1483
+ }
1484
+ async gzipAllowed() {
1485
+ if (this.gzipSupported !== null)
1486
+ return this.gzipSupported;
1487
+ try {
1488
+ const response = await fetch(`${this.credentials.origin()}/health`, {
1489
+ headers: { accept: "application/json", ...(0, identify_js_1.clientHeaders)() },
1490
+ signal: AbortSignal.timeout(8000),
1491
+ });
1492
+ const body = (await response.json());
1493
+ this.gzipSupported = Boolean(body.contentEncodings?.includes("gzip"));
1494
+ }
1495
+ catch {
1496
+ /* Unknown is treated as unsupported: never risk a refused upload. */
1497
+ this.gzipSupported = false;
1498
+ }
1499
+ return this.gzipSupported;
516
1500
  }
517
1501
  /** Files past the direct limit go up in parts under an upload session. */
518
1502
  async putMultipart(repositoryId, declaration, onOffset) {