@coderook/cli 0.22.0 → 0.22.2

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.
@@ -27,6 +27,8 @@ const profile_js_1 = require("./profile.js");
27
27
  const solid_js_1 = require("./solid.js");
28
28
  const retry_js_1 = require("./retry.js");
29
29
  const staging_js_1 = require("./staging.js");
30
+ const delta_js_1 = require("../shared/delta.js");
31
+ const telemetry_js_1 = require("../shared/telemetry.js");
30
32
  /** Above this the API insists on a multipart session. */
31
33
  const DIRECT_LIMIT = 95 * 1024 * 1024;
32
34
  /*
@@ -105,6 +107,21 @@ class Uploader {
105
107
  chunkingSupported = null;
106
108
  /** Null until the service has been asked; see packingAllowed. */
107
109
  packingSupported = null;
110
+ /** Null until the service has advertised its best changed-file transport. */
111
+ deltaProtocol = null;
112
+ /** Batch delta is separate so V2 single-file support remains compatible. */
113
+ deltaBatchSupported = null;
114
+ microchunkSupported = null;
115
+ /** One shared preflight, so parallel upload lanes cannot race /health. */
116
+ capabilityRequest = null;
117
+ telemetry = new telemetry_js_1.RepositoryTelemetry();
118
+ /** Planning runs the real partition/compression choices without body I/O. */
119
+ planning = false;
120
+ plannedObjects = new Map();
121
+ plannedQuote = null;
122
+ planningCreatedRepositoryId = null;
123
+ /** Attached to every object write after the server admits the exact plan. */
124
+ activeQuoteId = null;
108
125
  constructor(credentials) {
109
126
  this.credentials = credentials;
110
127
  }
@@ -138,8 +155,13 @@ class Uploader {
138
155
  // Its own cancellation first: `worthRetrying` knows nothing of it.
139
156
  if (error instanceof UploadCancelled)
140
157
  throw error;
158
+ // Completing a multipart upload changes server state. Repeating it can
159
+ // replace the useful first failure with "upload is not active".
160
+ if (init.retryable === false)
161
+ throw error;
141
162
  if (attempt >= retry_js_1.RETRY_ATTEMPTS || !(0, retry_js_1.worthRetrying)(error))
142
163
  throw error;
164
+ this.telemetry.retry();
143
165
  await this.pause(wait);
144
166
  wait *= 2;
145
167
  }
@@ -164,6 +186,7 @@ class Uploader {
164
186
  }
165
187
  /** A single attempt. Everything about repeating it lives in `call`. */
166
188
  async attempt(route, init) {
189
+ const started = performance.now();
167
190
  const token = await this.credentials.token();
168
191
  if (!token)
169
192
  throw new Error("Sign in again before uploading");
@@ -174,12 +197,17 @@ class Uploader {
174
197
  authorization: `Bearer ${token}`,
175
198
  "user-agent": "CodeRook/0.1",
176
199
  ...(0, identify_js_1.clientHeaders)(),
200
+ ...(this.activeQuoteId
201
+ ? { "x-coderook-upload-quote": this.activeQuoteId }
202
+ : {}),
177
203
  ...(init.contentType ? { "content-type": init.contentType } : {}),
178
204
  },
179
205
  body: init.body,
180
206
  signal: this.controller.signal,
181
207
  });
182
208
  const text = await response.text();
209
+ this.telemetry.request(route.split("?")[0] ?? route, init.body, new TextEncoder().encode(text).byteLength, performance.now() - started);
210
+ this.telemetry.memory(process.memoryUsage().rss);
183
211
  if (!response.ok) {
184
212
  /*
185
213
  Parsed only when it looks like JSON. An edge failure serves an HTML
@@ -224,7 +252,78 @@ class Uploader {
224
252
  throw new Error(`${route} returned a malformed reply`);
225
253
  }
226
254
  }
255
+ /**
256
+ * Perform the complete local compaction pass and ask the service whether its
257
+ * exact physical result fits. No object body is sent by this method.
258
+ */
259
+ async plan(request, report) {
260
+ this.planning = true;
261
+ this.activeQuoteId = null;
262
+ this.plannedQuote = null;
263
+ this.planningCreatedRepositoryId = null;
264
+ this.plannedObjects.clear();
265
+ try {
266
+ await this.runPass(request, report);
267
+ const quote = this.plannedQuote;
268
+ if (!quote)
269
+ throw new Error("Upload planning did not finish");
270
+ return {
271
+ ...quote,
272
+ repositoryCreated: this.planningCreatedRepositoryId === quote.repositoryId,
273
+ };
274
+ }
275
+ catch (error) {
276
+ if (this.planningCreatedRepositoryId) {
277
+ await this.call(`/v1/repositories/${this.planningCreatedRepositoryId}`, { method: "DELETE" }).catch(() => undefined);
278
+ }
279
+ throw error;
280
+ }
281
+ finally {
282
+ this.planning = false;
283
+ }
284
+ }
285
+ /** Release a review quote, and optionally its newly-created empty project. */
286
+ async cancelPlan(plan, removeCreatedRepository = false) {
287
+ await this.call(`/v1/repositories/${plan.repositoryId}/uploads/preflight/${plan.quoteId}`, { method: "DELETE" });
288
+ if (removeCreatedRepository && plan.repositoryCreated) {
289
+ await this.call(`/v1/repositories/${plan.repositoryId}`, { method: "DELETE" });
290
+ }
291
+ }
292
+ /** Plan first, then execute only the admitted immutable object set. */
227
293
  async run(request, report) {
294
+ const plan = await this.plan(request, report);
295
+ return this.execute(request, plan, report);
296
+ }
297
+ /** Execute a still-active quote produced by {@link plan}. */
298
+ async execute(request, plan, report) {
299
+ this.activeQuoteId = plan.quoteId;
300
+ try {
301
+ return await this.runPass({ ...request, repositoryId: plan.repositoryId }, report);
302
+ }
303
+ finally {
304
+ this.activeQuoteId = null;
305
+ }
306
+ }
307
+ rememberObject(definition) {
308
+ this.plannedObjects.set(definition.sha256, definition);
309
+ // Only used to let the planning pass build an in-memory candidate. It is
310
+ // never published or sent to the service.
311
+ return `00000000-0000-4000-8000-${definition.sha256.slice(0, 12)}`;
312
+ }
313
+ definition(sha256, size, mediaType, encoded, kind = "chunk", repositoryRole = "chunk") {
314
+ return {
315
+ sha256,
316
+ size,
317
+ storedSize: encoded.body.byteLength,
318
+ storedSha256: encoded.encoding === "gzip" ? encoded.storedSha256 : sha256,
319
+ mediaType,
320
+ kind,
321
+ repositoryRole,
322
+ encoding: encoded.encoding,
323
+ };
324
+ }
325
+ async runPass(request, report) {
326
+ this.telemetry = new telemetry_js_1.RepositoryTelemetry();
228
327
  // A version is a snapshot, not a delta, so it has to name every file in
229
328
  // the project — not merely the ones being sent this time. Anything
230
329
  // unchanged keeps the object the previous version already pointed at.
@@ -288,7 +387,23 @@ class Uploader {
288
387
  */
289
388
  const onDisk = new Set(everything.map((file) => file.path));
290
389
  const deletions = new Set(request.deletions ?? []);
390
+ const readFailures = new Map();
291
391
  const vanished = [...ticked].filter((chosen) => !onDisk.has(chosen) && !deletions.has(chosen));
392
+ /*
393
+ A caller may still hold a selection drawn before the rules changed. If
394
+ the file is on disk but absent from the filtered survey, it is excluded,
395
+ not unreadable. Naming that distinction matters: closing applications
396
+ cannot fix an ignore rule, while changing or removing the rule can.
397
+ */
398
+ for (const chosen of vanished) {
399
+ try {
400
+ await (0, promises_1.stat)(node_path_1.default.join(request.localPath, chosen));
401
+ readFailures.set(chosen, "excluded by the project's ignore rules");
402
+ }
403
+ catch (error) {
404
+ readFailures.set(chosen, error instanceof Error ? error.message : String(error));
405
+ }
406
+ }
292
407
  // 1. Measure and hash. This is what makes an unchanged file free.
293
408
  const declarations = [];
294
409
  let totalBytes = 0;
@@ -299,10 +414,11 @@ class Uploader {
299
414
  try {
300
415
  size = (await (0, promises_1.stat)(full)).size;
301
416
  }
302
- catch {
417
+ catch (error) {
303
418
  // Unreadable now, though it was listed a moment ago. Skipping it
304
419
  // would publish a version quietly missing a file the person chose.
305
420
  vanished.push(file.path);
421
+ readFailures.set(file.path, error instanceof Error ? error.message : String(error));
306
422
  continue;
307
423
  }
308
424
  report({
@@ -332,8 +448,9 @@ class Uploader {
332
448
  try {
333
449
  digest = await (0, profile_js_1.timed)("hash files", () => digestOf(full));
334
450
  }
335
- catch {
451
+ catch (error) {
336
452
  vanished.push(file.path);
453
+ readFailures.set(file.path, error instanceof Error ? error.message : String(error));
337
454
  continue;
338
455
  }
339
456
  declarations.push({
@@ -345,8 +462,15 @@ class Uploader {
345
462
  });
346
463
  totalBytes += size;
347
464
  }
348
- if (!declarations.length)
465
+ /*
466
+ A deletion-only save has no file to hash or upload, but it is still a
467
+ real new Version: the retained base minus the explicitly selected path.
468
+ Refusing it here made the object phase look healthy while preventing the
469
+ manifest-only operation that deletion is supposed to be.
470
+ */
471
+ if (!declarations.length && !deletions.size) {
349
472
  throw new Error("Nothing is selected to upload");
473
+ }
350
474
  // 2. The project needs somewhere on the account to live.
351
475
  this.check();
352
476
  let repositoryId = request.repositoryId;
@@ -368,6 +492,8 @@ class Uploader {
368
492
  }),
369
493
  });
370
494
  repositoryId = created.id;
495
+ if (this.planning)
496
+ this.planningCreatedRepositoryId = created.id;
371
497
  }
372
498
  catch (error) {
373
499
  // The account already has a project of this name — which happens
@@ -379,6 +505,10 @@ class Uploader {
379
505
  repositoryId = existing;
380
506
  }
381
507
  }
508
+ // Learn the wire contract once, before the two section pipelines begin.
509
+ // Otherwise their first feature questions race each other and a busy
510
+ // service can make one lane cache a false "unsupported" answer.
511
+ await this.serviceCapabilities();
382
512
  /*
383
513
  Sent in sections, not in one attempt.
384
514
 
@@ -394,6 +524,24 @@ class Uploader {
394
524
  */
395
525
  const allDeclarations = declarations;
396
526
  const declarationByPath = new Map(allDeclarations.map((one) => [one.path, one]));
527
+ /*
528
+ The narrow first use of the delta transport.
529
+
530
+ One changed small file used to miss solid packing (which correctly
531
+ requires a group) and upload its complete compressed bytes. When more
532
+ than one small file changes, the existing pack remains the better
533
+ answer and is deliberately untouched. New files have no receiver base
534
+ and also stay on the existing path.
535
+ */
536
+ const logicallyChanged = allDeclarations.filter((one) => prior.get(one.path)?.sha256 !== one.sha256);
537
+ const deltaDeclaration = logicallyChanged.length === 1 &&
538
+ request.baseVersionId &&
539
+ prior.has(logicallyChanged[0].path) &&
540
+ logicallyChanged[0].size <= BATCH_FILE_LIMIT &&
541
+ logicallyChanged[0].size <= delta_js_1.DELTA_MAX_FILE_BYTES &&
542
+ prior.get(logicallyChanged[0].path).sourceSize <= delta_js_1.DELTA_MAX_FILE_BYTES
543
+ ? logicallyChanged[0]
544
+ : null;
397
545
  const sections = (0, staging_js_1.planSections)(allDeclarations.map((one) => ({ path: one.path, size: one.size })));
398
546
  (0, profile_js_1.counted)("sections planned", sections.length);
399
547
  const uploaded = [];
@@ -544,7 +692,22 @@ class Uploader {
544
692
  an object of their own.
545
693
  */
546
694
  const packedInto = new Map();
547
- if (smallOnes.length > 1) {
695
+ if (deltaDeclaration &&
696
+ smallOnes.some((one) => one.path === deltaDeclaration.path)) {
697
+ const held = prior.get(deltaDeclaration.path);
698
+ const placed = await (0, profile_js_1.timed)("send changed-file delta", () => this.putDelta(repositoryId, request.baseVersionId, held, deltaDeclaration));
699
+ if (placed) {
700
+ batched.set(deltaDeclaration.sha256, {
701
+ objectId: placed.objectId,
702
+ size: placed.storedSize,
703
+ });
704
+ sentBytes += deltaDeclaration.size;
705
+ transferred += placed.sentBytes;
706
+ (0, profile_js_1.counted)("files sent as deltas", 1);
707
+ }
708
+ }
709
+ const smallForPacking = smallOnes.filter((one) => !batched.has(one.sha256));
710
+ if (smallForPacking.length > 1) {
548
711
  let pack = [];
549
712
  let packBytes = 0;
550
713
  /*
@@ -569,6 +732,7 @@ class Uploader {
569
732
  pack = [];
570
733
  packBytes = 0;
571
734
  const task = (async () => {
735
+ let exactWireBytes = null;
572
736
  try {
573
737
  /*
574
738
  Compressed together first. This is what the .cbx format is for and
@@ -577,18 +741,36 @@ class Uploader {
577
741
  and 30.2% compressed together, because a two-kilobyte file gives a
578
742
  compressor no dictionary worth having.
579
743
  */
580
- const placed = (await this.packingAllowed())
581
- ? await (0, profile_js_1.timed)("send packs", () => this.putPack(repositoryId, taking.map((item) => ({
582
- declaration: item.declaration,
744
+ const packItems = taking.map((item) => ({
745
+ declaration: item.declaration,
746
+ body: item.body,
747
+ }));
748
+ const built = (await this.packingAllowed())
749
+ ? await (0, solid_js_1.buildSolidPack)(packItems.map((item) => ({
750
+ sha256: item.declaration.sha256,
583
751
  body: item.body,
584
- }))))
752
+ })))
753
+ : null;
754
+ const packedBytes = built ? this.frameSolidPack(built).byteLength : 0;
755
+ const delta = built
756
+ ? await (0, profile_js_1.timed)("price changed-file batch", () => this.putDeltaBatch(repositoryId, request.baseVersionId, taking, prior, packedBytes))
757
+ : null;
758
+ if (delta) {
759
+ (0, profile_js_1.counted)("files sent as delta batch", taking.length);
760
+ exactWireBytes = delta.sentBytes;
761
+ for (const [digest, one] of delta.objects)
762
+ batched.set(digest, one);
763
+ }
764
+ const placed = !delta && built
765
+ ? await (0, profile_js_1.timed)("send packs", () => this.putPack(repositoryId, packItems, built))
585
766
  : null;
586
767
  if (placed) {
587
768
  (0, profile_js_1.counted)("files sent in packs", taking.length);
769
+ exactWireBytes = packedBytes;
588
770
  for (const [digest, one] of placed)
589
771
  packedInto.set(digest, one);
590
772
  }
591
- else {
773
+ else if (!delta) {
592
774
  /*
593
775
  Not worth packing — already-compressed content, or too few files.
594
776
  They still travel together, just as separate objects.
@@ -617,8 +799,11 @@ class Uploader {
617
799
  if (!batched.has(digest) && !packedInto.has(digest))
618
800
  continue;
619
801
  sentBytes += item.declaration.size;
620
- transferred += item.encoded.body.byteLength;
802
+ if (exactWireBytes === null)
803
+ transferred += item.encoded.body.byteLength;
621
804
  }
805
+ if (exactWireBytes !== null)
806
+ transferred += exactWireBytes;
622
807
  report({
623
808
  stage: "upload",
624
809
  files: 0,
@@ -634,7 +819,7 @@ class Uploader {
634
819
  if (flying.size >= BATCH_LANES)
635
820
  await Promise.race(flying);
636
821
  };
637
- for (const declaration of smallOnes) {
822
+ for (const declaration of smallForPacking) {
638
823
  this.check();
639
824
  let body;
640
825
  try {
@@ -699,7 +884,9 @@ class Uploader {
699
884
  whole: cutting it up would trade one request for several and save
700
885
  nothing.
701
886
  */
702
- const chunkProfile = (0, chunking_js_1.profileForFileSize)(declaration.size);
887
+ const chunkProfile = (await this.microchunkAllowed())
888
+ ? (0, chunking_js_1.microchunkProfileForFileSize)(declaration.size)
889
+ : (0, chunking_js_1.profileForFileSize)(declaration.size);
703
890
  if (chunkProfile &&
704
891
  declaration.size >= CHUNK_THRESHOLD &&
705
892
  (await this.chunkingAllowed())) {
@@ -813,6 +1000,41 @@ class Uploader {
813
1000
  }
814
1001
  };
815
1002
  await Promise.all([pipeline(batchy), pipeline(heavy)]);
1003
+ if (this.planning) {
1004
+ report({
1005
+ stage: "publish",
1006
+ files: declarations.length,
1007
+ totalFiles: declarations.length,
1008
+ bytes: sentBytes,
1009
+ totalBytes,
1010
+ path: "Checking storage and monthly allowance",
1011
+ percent: 96,
1012
+ bytesPerSecond: 0,
1013
+ });
1014
+ this.plannedQuote = await this.call(`/v1/repositories/${repositoryId}/uploads/preflight`, {
1015
+ method: "POST",
1016
+ contentType: "application/json",
1017
+ body: JSON.stringify({
1018
+ sourceBytes: totalBytes,
1019
+ excludedBytes: 0,
1020
+ objects: [...this.plannedObjects.values()],
1021
+ }),
1022
+ });
1023
+ return {
1024
+ repositoryId,
1025
+ versionId: "",
1026
+ sequence: 0,
1027
+ sourceBytes: totalBytes,
1028
+ storedBytes: this.plannedQuote.compactedBytes,
1029
+ sentBytes: this.plannedQuote.chargeableBytes,
1030
+ sentFiles: Object.values(this.plannedQuote.objects).filter((object) => object.needsUpload).length,
1031
+ reusedFiles: reused.length,
1032
+ alreadyStoredFiles: 0,
1033
+ manifest: {},
1034
+ local: {},
1035
+ telemetry: this.telemetry.snapshot(),
1036
+ };
1037
+ }
816
1038
  // 4. Name the version, which is what makes the upload visible.
817
1039
  this.check();
818
1040
  report({
@@ -885,6 +1107,11 @@ class Uploader {
885
1107
  if (dropped.length) {
886
1108
  const shown = dropped.slice(0, 5).join(", ");
887
1109
  const rest = dropped.length > 5 ? ` and ${dropped.length - 5} more` : "";
1110
+ const details = dropped
1111
+ .map((name) => readFailures.get(name) ? `${name}: ${readFailures.get(name)}` : "")
1112
+ .filter(Boolean)
1113
+ .slice(0, 3)
1114
+ .join("; ");
888
1115
  /*
889
1116
  Say what to do about it. Refusing is right — a version quietly missing
890
1117
  a file somebody chose is the one failure a backup tool must never have
@@ -893,10 +1120,14 @@ class Uploader {
893
1120
  holds them or unticking them. Almost every case is a file another
894
1121
  program has open, so that is what it says.
895
1122
  */
896
- throw new Error(`${dropped.length} selected file${dropped.length === 1 ? "" : "s"} could not be read, ` +
897
- `so nothing was saved: ${shown}${rest}. This usually means another program ` +
898
- `has them open. Close it and try again, or untick them in the file list. ` +
899
- `Nothing was changed on your account.`);
1123
+ const excluded = dropped.some((name) => readFailures.get(name)?.includes("ignore rules"));
1124
+ throw new Error(`${dropped.length} selected file${dropped.length === 1 ? "" : "s"} could not be included, ` +
1125
+ `so nothing was saved: ${shown}${rest}. ` +
1126
+ (excluded
1127
+ ? `At least one is excluded by the project's ignore rules. Change the rules or untick it and try again. `
1128
+ : `Another program may have a file open. Close it or untick the file and try again. `) +
1129
+ `Nothing was changed on your account.` +
1130
+ (details ? ` Details: ${details}` : ""));
900
1131
  }
901
1132
  const sourceBytes = contents.reduce((total, item) => total + item.sourceSize, 0);
902
1133
  const storedBytes = contents.reduce((total, item) => total + item.storedSize, 0);
@@ -1000,6 +1231,7 @@ class Uploader {
1000
1231
  reusedFiles: reused.length,
1001
1232
  /** Selected, but the service already held the content. */
1002
1233
  alreadyStoredFiles: alreadyOnAccount,
1234
+ telemetry: this.telemetry.snapshot(),
1003
1235
  // A file that kept its old object records the digest of *that* copy,
1004
1236
  // not of the file on disk, so an unticked edit is still pending next
1005
1237
  // time rather than looking as though it had been saved.
@@ -1128,24 +1360,38 @@ class Uploader {
1128
1360
  * service would not take it — in which case the caller falls back to sending
1129
1361
  * them separately, which is slower and larger but always works.
1130
1362
  */
1131
- async putPack(repositoryId, items) {
1132
- const built = await (0, solid_js_1.buildSolidPack)(items.map((item) => ({
1363
+ async putPack(repositoryId, items, alreadyBuilt) {
1364
+ const built = alreadyBuilt ?? await (0, solid_js_1.buildSolidPack)(items.map((item) => ({
1133
1365
  sha256: item.declaration.sha256,
1134
1366
  body: item.body,
1135
1367
  })));
1136
1368
  if (!built)
1137
1369
  return null;
1138
- const manifest = JSON.stringify({
1139
- sha256: built.sha256,
1140
- storedSha256: built.storedSha256,
1141
- size: built.size,
1142
- members: built.members,
1143
- });
1144
- const manifestBytes = new TextEncoder().encode(manifest);
1145
- const packed = new Uint8Array(4 + manifestBytes.byteLength + built.body.byteLength);
1146
- new DataView(packed.buffer).setUint32(0, manifestBytes.byteLength, false);
1147
- packed.set(manifestBytes, 4);
1148
- packed.set(built.body, 4 + manifestBytes.byteLength);
1370
+ const packed = this.frameSolidPack(built);
1371
+ if (this.planning) {
1372
+ const objectId = this.rememberObject({
1373
+ sha256: built.sha256,
1374
+ size: built.size,
1375
+ storedSize: built.body.byteLength,
1376
+ storedSha256: built.storedSha256,
1377
+ mediaType: "application/octet-stream",
1378
+ kind: "solid_pack",
1379
+ repositoryRole: "bundle",
1380
+ encoding: "gzip",
1381
+ });
1382
+ const placed = new Map();
1383
+ for (const member of built.members) {
1384
+ placed.set(member.sha256, {
1385
+ packObjectId: objectId,
1386
+ offset: member.offset,
1387
+ length: member.length,
1388
+ storedSize: built.size
1389
+ ? Math.round((member.length / built.size) * built.body.byteLength)
1390
+ : 0,
1391
+ });
1392
+ }
1393
+ return placed;
1394
+ }
1149
1395
  const answer = await this.call(`/v1/repositories/${repositoryId}/objects/pack`, {
1150
1396
  method: "POST",
1151
1397
  contentType: "application/octet-stream",
@@ -1174,6 +1420,235 @@ class Uploader {
1174
1420
  }
1175
1421
  return placed;
1176
1422
  }
1423
+ /** The exact HTTP body used for the solid-pack alternative. */
1424
+ frameSolidPack(built) {
1425
+ const manifestBytes = new TextEncoder().encode(JSON.stringify({
1426
+ sha256: built.sha256,
1427
+ storedSha256: built.storedSha256,
1428
+ size: built.size,
1429
+ members: built.members,
1430
+ }));
1431
+ const packed = new Uint8Array(4 + manifestBytes.byteLength + built.body.byteLength);
1432
+ new DataView(packed.buffer).setUint32(0, manifestBytes.byteLength, false);
1433
+ packed.set(manifestBytes, 4);
1434
+ packed.set(built.body, 4 + manifestBytes.byteLength);
1435
+ return packed;
1436
+ }
1437
+ /**
1438
+ * Price several independent file deltas against the already-built solid
1439
+ * pack. The batch is optional and may only replace the pack when every
1440
+ * member has an immutable base and the complete two-way wire cost wins by
1441
+ * the same twenty-percent margin used by the single-file transport.
1442
+ */
1443
+ async putDeltaBatch(repositoryId, baseVersionId, items, prior, solidPackBytes) {
1444
+ if (!baseVersionId ||
1445
+ solidPackBytes <= 0 ||
1446
+ items.length < 2 ||
1447
+ items.length > delta_js_1.DELTA_BATCH_MAX_FILES ||
1448
+ !(await this.deltaBatchAllowed()))
1449
+ return null;
1450
+ let sourceBytes = 0;
1451
+ let signatureEstimate = 6 + items.length * 4;
1452
+ const bases = [];
1453
+ for (const item of items) {
1454
+ const base = prior.get(item.declaration.path);
1455
+ if (!base ||
1456
+ !base.sha256 ||
1457
+ item.declaration.size > delta_js_1.DELTA_MAX_FILE_BYTES ||
1458
+ base.sourceSize > delta_js_1.DELTA_MAX_FILE_BYTES)
1459
+ return null;
1460
+ if ((0, node_crypto_1.createHash)("sha256").update(item.body).digest("hex") !==
1461
+ item.declaration.sha256)
1462
+ return null;
1463
+ sourceBytes += item.declaration.size;
1464
+ if (sourceBytes > delta_js_1.DELTA_BATCH_MAX_SOURCE_BYTES)
1465
+ return null;
1466
+ signatureEstimate += (0, delta_js_1.estimateDeltaSignatureBytes)(base.sourceSize, 2);
1467
+ bases.push(base);
1468
+ }
1469
+ if (signatureEstimate >= solidPackBytes * 0.8)
1470
+ return null;
1471
+ try {
1472
+ const token = await this.credentials.token();
1473
+ if (!token)
1474
+ return null;
1475
+ const response = await fetch(`${this.credentials.origin()}/v1/repositories/${repositoryId}/objects/delta/signatures`, {
1476
+ method: "POST",
1477
+ headers: {
1478
+ accept: "application/vnd.coderook.delta-signature-batch; version=2",
1479
+ authorization: `Bearer ${token}`,
1480
+ "content-type": "application/json",
1481
+ "user-agent": "CodeRook/0.1",
1482
+ ...(0, identify_js_1.clientHeaders)(),
1483
+ },
1484
+ body: JSON.stringify({
1485
+ baseVersionId,
1486
+ files: items.map((item, index) => ({
1487
+ path: item.declaration.path,
1488
+ baseSha256: bases[index].sha256,
1489
+ })),
1490
+ }),
1491
+ signal: this.controller.signal,
1492
+ });
1493
+ if (!response.ok)
1494
+ return null;
1495
+ const signatureBytes = new Uint8Array(await response.arrayBuffer());
1496
+ const signatures = (0, delta_js_1.decodeDeltaSignatureBatch)(signatureBytes);
1497
+ if (signatures.length !== items.length)
1498
+ return null;
1499
+ const framed = (0, delta_js_1.encodeDeltaBatchEnvelope)({
1500
+ baseVersionId,
1501
+ entries: items.map((item, index) => ({
1502
+ path: item.declaration.path,
1503
+ sha256: item.declaration.sha256,
1504
+ mediaType: item.declaration.mediaType,
1505
+ patch: (0, delta_js_1.createDeltaPatch)(item.body, signatures[index]),
1506
+ })),
1507
+ });
1508
+ const wireBytes = signatureBytes.byteLength + framed.byteLength;
1509
+ if (wireBytes >= solidPackBytes * 0.8)
1510
+ return null;
1511
+ if (this.planning) {
1512
+ const objects = new Map();
1513
+ for (const item of items) {
1514
+ const definition = this.definition(item.declaration.sha256, item.body.byteLength, item.declaration.mediaType, item.encoded);
1515
+ objects.set(item.declaration.sha256, {
1516
+ objectId: this.rememberObject(definition),
1517
+ size: definition.storedSize,
1518
+ });
1519
+ }
1520
+ return { objects, sentBytes: wireBytes };
1521
+ }
1522
+ const answer = await this.call(`/v1/repositories/${repositoryId}/objects/delta/batch`, {
1523
+ method: "POST",
1524
+ contentType: "application/octet-stream",
1525
+ body: framed,
1526
+ });
1527
+ if (answer.objects.length !== items.length)
1528
+ return null;
1529
+ const objects = new Map();
1530
+ for (const object of answer.objects) {
1531
+ if (!items.some((item) => item.declaration.sha256 === object.sha256)) {
1532
+ return null;
1533
+ }
1534
+ objects.set(object.sha256, {
1535
+ objectId: object.objectId,
1536
+ size: object.storedSize,
1537
+ });
1538
+ }
1539
+ const expectedDigests = new Set(items.map((item) => item.declaration.sha256)).size;
1540
+ return objects.size === expectedDigests
1541
+ ? { objects, sentBytes: wireBytes }
1542
+ : null;
1543
+ }
1544
+ catch {
1545
+ return null;
1546
+ }
1547
+ }
1548
+ /**
1549
+ * Try the negotiated receiver-signature transport for one changed file.
1550
+ *
1551
+ * Any refusal, old deployment, stale base, or poor result returns null and
1552
+ * the caller sends the complete object exactly as it did before. The patch
1553
+ * is never a repository object: the service reconstructs and verifies the
1554
+ * complete target before returning the ordinary object id used below.
1555
+ */
1556
+ async putDelta(repositoryId, baseVersionId, prior, declaration) {
1557
+ const deltaVersion = await this.deltaVersion();
1558
+ if (deltaVersion === 0)
1559
+ return null;
1560
+ let target;
1561
+ try {
1562
+ target = new Uint8Array(await (0, promises_1.readFile)(declaration.full));
1563
+ }
1564
+ catch {
1565
+ return null;
1566
+ }
1567
+ const digest = (0, node_crypto_1.createHash)("sha256").update(target).digest("hex");
1568
+ if (digest !== declaration.sha256)
1569
+ return null;
1570
+ const ordinary = await (0, compress_js_1.encodeForUpload)(target, declaration.path, await this.gzipAllowed());
1571
+ const signatureEstimate = (0, delta_js_1.estimateDeltaSignatureBytes)(prior.sourceSize, deltaVersion);
1572
+ if (signatureEstimate >= ordinary.body.byteLength * 0.8)
1573
+ return null;
1574
+ try {
1575
+ const signatureBytes = await this.deltaSignature(repositoryId, baseVersionId, declaration.path, prior.sha256, deltaVersion);
1576
+ const signature = (0, delta_js_1.parseDeltaSignature)(signatureBytes);
1577
+ const patch = (0, delta_js_1.createDeltaPatch)(target, signature);
1578
+ let framed;
1579
+ if (signature.version === 2) {
1580
+ framed = (0, delta_js_1.encodeDeltaEnvelope)({
1581
+ baseVersionId,
1582
+ path: declaration.path,
1583
+ sha256: declaration.sha256,
1584
+ mediaType: declaration.mediaType,
1585
+ patch,
1586
+ });
1587
+ }
1588
+ else {
1589
+ const manifest = new TextEncoder().encode(JSON.stringify({
1590
+ baseVersionId,
1591
+ path: declaration.path,
1592
+ baseSha256: prior.sha256,
1593
+ sha256: declaration.sha256,
1594
+ size: target.byteLength,
1595
+ mediaType: declaration.mediaType,
1596
+ }));
1597
+ framed = new Uint8Array(4 + manifest.byteLength + patch.byteLength);
1598
+ new DataView(framed.buffer).setUint32(0, manifest.byteLength, false);
1599
+ framed.set(manifest, 4);
1600
+ framed.set(patch, 4 + manifest.byteLength);
1601
+ }
1602
+ // Count both directions. A patch that only looks small because its
1603
+ // receiver signature was ignored is not an improvement.
1604
+ const wireBytes = signatureBytes.byteLength + framed.byteLength;
1605
+ if (wireBytes >= ordinary.body.byteLength * 0.8)
1606
+ return null;
1607
+ if (this.planning) {
1608
+ const definition = this.definition(declaration.sha256, target.byteLength, declaration.mediaType, ordinary);
1609
+ return {
1610
+ objectId: this.rememberObject(definition),
1611
+ storedSize: definition.storedSize,
1612
+ sentBytes: wireBytes,
1613
+ };
1614
+ }
1615
+ const answer = await this.call(`/v1/repositories/${repositoryId}/objects/delta`, {
1616
+ method: "POST",
1617
+ contentType: "application/octet-stream",
1618
+ body: framed,
1619
+ });
1620
+ return {
1621
+ objectId: answer.objectId,
1622
+ storedSize: answer.storedSize,
1623
+ sentBytes: wireBytes,
1624
+ };
1625
+ }
1626
+ catch {
1627
+ // This is an optional transport. The complete-object path is authority.
1628
+ return null;
1629
+ }
1630
+ }
1631
+ async deltaSignature(repositoryId, baseVersionId, filePath, baseSha256, version) {
1632
+ const token = await this.credentials.token();
1633
+ if (!token)
1634
+ throw new Error("Sign in again before uploading");
1635
+ const response = await fetch(`${this.credentials.origin()}/v1/repositories/${repositoryId}/objects/delta/signature`, {
1636
+ method: "POST",
1637
+ headers: {
1638
+ accept: `application/vnd.coderook.delta-signature; version=${version}`,
1639
+ authorization: `Bearer ${token}`,
1640
+ "content-type": "application/json",
1641
+ "user-agent": "CodeRook/0.1",
1642
+ ...(0, identify_js_1.clientHeaders)(),
1643
+ },
1644
+ body: JSON.stringify({ baseVersionId, path: filePath, baseSha256 }),
1645
+ signal: this.controller.signal,
1646
+ });
1647
+ if (!response.ok) {
1648
+ throw new Error(`Delta signature failed with HTTP ${response.status}`);
1649
+ }
1650
+ return new Uint8Array(await response.arrayBuffer());
1651
+ }
1177
1652
  /**
1178
1653
  * Send many small objects in one request.
1179
1654
  *
@@ -1210,6 +1685,16 @@ class Uploader {
1210
1685
  packed.set(item.encoded.body, at);
1211
1686
  at += item.encoded.body.byteLength;
1212
1687
  }
1688
+ if (this.planning) {
1689
+ for (const item of items) {
1690
+ const definition = this.definition(item.declaration.sha256, item.body.byteLength, item.declaration.mediaType, item.encoded);
1691
+ landed.set(item.declaration.sha256, {
1692
+ objectId: this.rememberObject(definition),
1693
+ size: definition.storedSize,
1694
+ });
1695
+ }
1696
+ return landed;
1697
+ }
1213
1698
  const answer = await this.call(`/v1/repositories/${repositoryId}/objects/batch`, {
1214
1699
  method: "POST",
1215
1700
  contentType: "application/octet-stream",
@@ -1420,6 +1905,16 @@ class Uploader {
1420
1905
  `&logicalSize=${piece.length}` +
1421
1906
  `&storedSha256=${encoded.storedSha256}`
1422
1907
  : `?kind=chunk&role=chunk`;
1908
+ if (this.planning) {
1909
+ const definition = this.definition(digest, piece.length, declaration.mediaType, encoded);
1910
+ note("sent", encoded.body.byteLength);
1911
+ chunks[at] = {
1912
+ objectId: this.rememberObject(definition),
1913
+ sourceSize: piece.length,
1914
+ storedSize: definition.storedSize,
1915
+ };
1916
+ return;
1917
+ }
1423
1918
  const stored = await this.call(`/v1/repositories/${repositoryId}/objects/${digest}${query}`, {
1424
1919
  method: "PUT",
1425
1920
  contentType: "application/octet-stream",
@@ -1461,6 +1956,13 @@ class Uploader {
1461
1956
  `&logicalSize=${body.length}` +
1462
1957
  `&storedSha256=${encoded.storedSha256}`
1463
1958
  : `?kind=chunk&role=chunk`;
1959
+ if (this.planning) {
1960
+ const definition = this.definition(digest, body.length, declaration.mediaType, encoded);
1961
+ return {
1962
+ objectId: this.rememberObject(definition),
1963
+ size: definition.storedSize,
1964
+ };
1965
+ }
1464
1966
  /*
1465
1967
  The answer carries both sizes and they are not the same thing: `size` is
1466
1968
  the file's own length, `storedSize` is what the service actually keeps.
@@ -1501,46 +2003,86 @@ class Uploader {
1501
2003
  this.packingSupported = (await this.serviceFeatures()).includes("solid-packs");
1502
2004
  return this.packingSupported;
1503
2005
  }
2006
+ async microchunkAllowed() {
2007
+ if (this.microchunkSupported !== null)
2008
+ return this.microchunkSupported;
2009
+ this.microchunkSupported = (await this.serviceFeatures()).includes("microchunk-map-v1");
2010
+ return this.microchunkSupported;
2011
+ }
2012
+ async deltaVersion() {
2013
+ if (this.deltaProtocol !== null)
2014
+ return this.deltaProtocol;
2015
+ const features = await this.serviceFeatures();
2016
+ this.deltaProtocol = features.includes("delta-transport-v2")
2017
+ ? 2
2018
+ : features.includes("delta-transport")
2019
+ ? 1
2020
+ : 0;
2021
+ return this.deltaProtocol;
2022
+ }
2023
+ async deltaBatchAllowed() {
2024
+ if (this.deltaBatchSupported !== null)
2025
+ return this.deltaBatchSupported;
2026
+ this.deltaBatchSupported = (await this.serviceFeatures()).includes("delta-batch-v2");
2027
+ return this.deltaBatchSupported;
2028
+ }
1504
2029
  async chunkingAllowed() {
1505
2030
  if (this.chunkingSupported !== null)
1506
2031
  return this.chunkingSupported;
1507
2032
  this.chunkingSupported = (await this.serviceFeatures()).includes("chunked-files");
1508
2033
  return this.chunkingSupported;
1509
2034
  }
1510
- /** What /health says this deployment accepts. Fetched once. */
2035
+ /** What /health says this deployment accepts. One request per uploader. */
2036
+ async serviceCapabilities() {
2037
+ if (this.capabilityRequest)
2038
+ return this.capabilityRequest;
2039
+ this.capabilityRequest = (async () => {
2040
+ try {
2041
+ const response = await fetch(`${this.credentials.origin()}/health`, {
2042
+ headers: { accept: "application/json", ...(0, identify_js_1.clientHeaders)() },
2043
+ signal: AbortSignal.timeout(8000),
2044
+ });
2045
+ if (!response.ok)
2046
+ throw new Error(`health failed (${response.status})`);
2047
+ const body = (await response.json());
2048
+ return {
2049
+ features: body.features ?? [],
2050
+ contentEncodings: body.contentEncodings ?? ["identity"],
2051
+ };
2052
+ }
2053
+ catch {
2054
+ /* Unknown is treated as unsupported: never risk a refused upload. */
2055
+ return { features: [], contentEncodings: ["identity"] };
2056
+ }
2057
+ })();
2058
+ return this.capabilityRequest;
2059
+ }
1511
2060
  async serviceFeatures() {
1512
- try {
1513
- const response = await fetch(`${this.credentials.origin()}/health`, {
1514
- headers: { accept: "application/json", ...(0, identify_js_1.clientHeaders)() },
1515
- signal: AbortSignal.timeout(8000),
1516
- });
1517
- const body = (await response.json());
1518
- return body.features ?? [];
1519
- }
1520
- catch {
1521
- /* Unknown is treated as unsupported: never risk a refused upload. */
1522
- return [];
1523
- }
2061
+ return (await this.serviceCapabilities()).features;
1524
2062
  }
1525
2063
  async gzipAllowed() {
1526
2064
  if (this.gzipSupported !== null)
1527
2065
  return this.gzipSupported;
1528
- try {
1529
- const response = await fetch(`${this.credentials.origin()}/health`, {
1530
- headers: { accept: "application/json", ...(0, identify_js_1.clientHeaders)() },
1531
- signal: AbortSignal.timeout(8000),
1532
- });
1533
- const body = (await response.json());
1534
- this.gzipSupported = Boolean(body.contentEncodings?.includes("gzip"));
1535
- }
1536
- catch {
1537
- /* Unknown is treated as unsupported: never risk a refused upload. */
1538
- this.gzipSupported = false;
1539
- }
2066
+ const body = await this.serviceCapabilities();
2067
+ this.gzipSupported = body.contentEncodings.includes("gzip");
1540
2068
  return this.gzipSupported;
1541
2069
  }
1542
2070
  /** Files past the direct limit go up in parts under an upload session. */
1543
2071
  async putMultipart(repositoryId, declaration, onOffset) {
2072
+ if (this.planning) {
2073
+ const objectId = this.rememberObject({
2074
+ sha256: declaration.sha256,
2075
+ size: declaration.size,
2076
+ storedSize: declaration.size,
2077
+ storedSha256: declaration.sha256,
2078
+ mediaType: declaration.mediaType,
2079
+ kind: "chunk",
2080
+ repositoryRole: "chunk",
2081
+ encoding: "identity",
2082
+ });
2083
+ onOffset(declaration.size);
2084
+ return { objectId, size: declaration.size };
2085
+ }
1544
2086
  const session = await this.call(`/v1/repositories/${repositoryId}/uploads`, {
1545
2087
  method: "POST",
1546
2088
  contentType: "application/json",
@@ -1568,7 +2110,12 @@ class Uploader {
1568
2110
  partNumber += 1;
1569
2111
  onOffset(offset);
1570
2112
  }
1571
- return await this.call(`/v1/uploads/${session.uploadSessionId}/complete`, { method: "POST", contentType: "application/json", body: "{}" });
2113
+ return await this.call(`/v1/uploads/${session.uploadSessionId}/complete`, {
2114
+ method: "POST",
2115
+ contentType: "application/json",
2116
+ body: "{}",
2117
+ retryable: false,
2118
+ });
1572
2119
  }
1573
2120
  catch (error) {
1574
2121
  // A half-finished session would hold storage forever.