@coderook/cli 0.22.1 → 0.23.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.
@@ -28,6 +28,7 @@ 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
30
  const delta_js_1 = require("../shared/delta.js");
31
+ const telemetry_js_1 = require("../shared/telemetry.js");
31
32
  /** Above this the API insists on a multipart session. */
32
33
  const DIRECT_LIMIT = 95 * 1024 * 1024;
33
34
  /*
@@ -110,6 +111,17 @@ class Uploader {
110
111
  deltaProtocol = null;
111
112
  /** Batch delta is separate so V2 single-file support remains compatible. */
112
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;
113
125
  constructor(credentials) {
114
126
  this.credentials = credentials;
115
127
  }
@@ -143,8 +155,13 @@ class Uploader {
143
155
  // Its own cancellation first: `worthRetrying` knows nothing of it.
144
156
  if (error instanceof UploadCancelled)
145
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;
146
162
  if (attempt >= retry_js_1.RETRY_ATTEMPTS || !(0, retry_js_1.worthRetrying)(error))
147
163
  throw error;
164
+ this.telemetry.retry();
148
165
  await this.pause(wait);
149
166
  wait *= 2;
150
167
  }
@@ -169,6 +186,7 @@ class Uploader {
169
186
  }
170
187
  /** A single attempt. Everything about repeating it lives in `call`. */
171
188
  async attempt(route, init) {
189
+ const started = performance.now();
172
190
  const token = await this.credentials.token();
173
191
  if (!token)
174
192
  throw new Error("Sign in again before uploading");
@@ -179,12 +197,17 @@ class Uploader {
179
197
  authorization: `Bearer ${token}`,
180
198
  "user-agent": "CodeRook/0.1",
181
199
  ...(0, identify_js_1.clientHeaders)(),
200
+ ...(this.activeQuoteId
201
+ ? { "x-coderook-upload-quote": this.activeQuoteId }
202
+ : {}),
182
203
  ...(init.contentType ? { "content-type": init.contentType } : {}),
183
204
  },
184
205
  body: init.body,
185
206
  signal: this.controller.signal,
186
207
  });
187
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);
188
211
  if (!response.ok) {
189
212
  /*
190
213
  Parsed only when it looks like JSON. An edge failure serves an HTML
@@ -229,11 +252,91 @@ class Uploader {
229
252
  throw new Error(`${route} returned a malformed reply`);
230
253
  }
231
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. */
232
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();
233
327
  // A version is a snapshot, not a delta, so it has to name every file in
234
328
  // the project — not merely the ones being sent this time. Anything
235
329
  // unchanged keeps the object the previous version already pointed at.
236
- const rules = await (0, worktree_js_1.readRules)(request.localPath);
330
+ /*
331
+ An import filters nothing. The rules keep local mess out of a working
332
+ folder, but an imported tree is exactly what the source repository
333
+ tracked — there is no mess in it, and anything the source tracked in
334
+ spite of its own rules (which git does, for files committed before the
335
+ rule) would otherwise be dropped without being mentioned.
336
+ */
337
+ const rules = request.allowIgnored
338
+ ? { shared: "", local: "" }
339
+ : await (0, worktree_js_1.readRules)(request.localPath);
237
340
  /*
238
341
  Surveyed, not listed.
239
342
 
@@ -293,7 +396,23 @@ class Uploader {
293
396
  */
294
397
  const onDisk = new Set(everything.map((file) => file.path));
295
398
  const deletions = new Set(request.deletions ?? []);
399
+ const readFailures = new Map();
296
400
  const vanished = [...ticked].filter((chosen) => !onDisk.has(chosen) && !deletions.has(chosen));
401
+ /*
402
+ A caller may still hold a selection drawn before the rules changed. If
403
+ the file is on disk but absent from the filtered survey, it is excluded,
404
+ not unreadable. Naming that distinction matters: closing applications
405
+ cannot fix an ignore rule, while changing or removing the rule can.
406
+ */
407
+ for (const chosen of vanished) {
408
+ try {
409
+ await (0, promises_1.stat)(node_path_1.default.join(request.localPath, chosen));
410
+ readFailures.set(chosen, "excluded by the project's ignore rules");
411
+ }
412
+ catch (error) {
413
+ readFailures.set(chosen, error instanceof Error ? error.message : String(error));
414
+ }
415
+ }
297
416
  // 1. Measure and hash. This is what makes an unchanged file free.
298
417
  const declarations = [];
299
418
  let totalBytes = 0;
@@ -304,10 +423,11 @@ class Uploader {
304
423
  try {
305
424
  size = (await (0, promises_1.stat)(full)).size;
306
425
  }
307
- catch {
426
+ catch (error) {
308
427
  // Unreadable now, though it was listed a moment ago. Skipping it
309
428
  // would publish a version quietly missing a file the person chose.
310
429
  vanished.push(file.path);
430
+ readFailures.set(file.path, error instanceof Error ? error.message : String(error));
311
431
  continue;
312
432
  }
313
433
  report({
@@ -337,8 +457,9 @@ class Uploader {
337
457
  try {
338
458
  digest = await (0, profile_js_1.timed)("hash files", () => digestOf(full));
339
459
  }
340
- catch {
460
+ catch (error) {
341
461
  vanished.push(file.path);
462
+ readFailures.set(file.path, error instanceof Error ? error.message : String(error));
342
463
  continue;
343
464
  }
344
465
  declarations.push({
@@ -380,6 +501,8 @@ class Uploader {
380
501
  }),
381
502
  });
382
503
  repositoryId = created.id;
504
+ if (this.planning)
505
+ this.planningCreatedRepositoryId = created.id;
383
506
  }
384
507
  catch (error) {
385
508
  // The account already has a project of this name — which happens
@@ -391,6 +514,10 @@ class Uploader {
391
514
  repositoryId = existing;
392
515
  }
393
516
  }
517
+ // Learn the wire contract once, before the two section pipelines begin.
518
+ // Otherwise their first feature questions race each other and a busy
519
+ // service can make one lane cache a false "unsupported" answer.
520
+ await this.serviceCapabilities();
394
521
  /*
395
522
  Sent in sections, not in one attempt.
396
523
 
@@ -766,7 +893,9 @@ class Uploader {
766
893
  whole: cutting it up would trade one request for several and save
767
894
  nothing.
768
895
  */
769
- const chunkProfile = (0, chunking_js_1.profileForFileSize)(declaration.size);
896
+ const chunkProfile = (await this.microchunkAllowed())
897
+ ? (0, chunking_js_1.microchunkProfileForFileSize)(declaration.size)
898
+ : (0, chunking_js_1.profileForFileSize)(declaration.size);
770
899
  if (chunkProfile &&
771
900
  declaration.size >= CHUNK_THRESHOLD &&
772
901
  (await this.chunkingAllowed())) {
@@ -880,6 +1009,41 @@ class Uploader {
880
1009
  }
881
1010
  };
882
1011
  await Promise.all([pipeline(batchy), pipeline(heavy)]);
1012
+ if (this.planning) {
1013
+ report({
1014
+ stage: "publish",
1015
+ files: declarations.length,
1016
+ totalFiles: declarations.length,
1017
+ bytes: sentBytes,
1018
+ totalBytes,
1019
+ path: "Checking storage and monthly allowance",
1020
+ percent: 96,
1021
+ bytesPerSecond: 0,
1022
+ });
1023
+ this.plannedQuote = await this.call(`/v1/repositories/${repositoryId}/uploads/preflight`, {
1024
+ method: "POST",
1025
+ contentType: "application/json",
1026
+ body: JSON.stringify({
1027
+ sourceBytes: totalBytes,
1028
+ excludedBytes: 0,
1029
+ objects: [...this.plannedObjects.values()],
1030
+ }),
1031
+ });
1032
+ return {
1033
+ repositoryId,
1034
+ versionId: "",
1035
+ sequence: 0,
1036
+ sourceBytes: totalBytes,
1037
+ storedBytes: this.plannedQuote.compactedBytes,
1038
+ sentBytes: this.plannedQuote.chargeableBytes,
1039
+ sentFiles: Object.values(this.plannedQuote.objects).filter((object) => object.needsUpload).length,
1040
+ reusedFiles: reused.length,
1041
+ alreadyStoredFiles: 0,
1042
+ manifest: {},
1043
+ local: {},
1044
+ telemetry: this.telemetry.snapshot(),
1045
+ };
1046
+ }
883
1047
  // 4. Name the version, which is what makes the upload visible.
884
1048
  this.check();
885
1049
  report({
@@ -952,6 +1116,11 @@ class Uploader {
952
1116
  if (dropped.length) {
953
1117
  const shown = dropped.slice(0, 5).join(", ");
954
1118
  const rest = dropped.length > 5 ? ` and ${dropped.length - 5} more` : "";
1119
+ const details = dropped
1120
+ .map((name) => readFailures.get(name) ? `${name}: ${readFailures.get(name)}` : "")
1121
+ .filter(Boolean)
1122
+ .slice(0, 3)
1123
+ .join("; ");
955
1124
  /*
956
1125
  Say what to do about it. Refusing is right — a version quietly missing
957
1126
  a file somebody chose is the one failure a backup tool must never have
@@ -960,10 +1129,14 @@ class Uploader {
960
1129
  holds them or unticking them. Almost every case is a file another
961
1130
  program has open, so that is what it says.
962
1131
  */
963
- throw new Error(`${dropped.length} selected file${dropped.length === 1 ? "" : "s"} could not be read, ` +
964
- `so nothing was saved: ${shown}${rest}. This usually means another program ` +
965
- `has them open. Close it and try again, or untick them in the file list. ` +
966
- `Nothing was changed on your account.`);
1132
+ const excluded = dropped.some((name) => readFailures.get(name)?.includes("ignore rules"));
1133
+ throw new Error(`${dropped.length} selected file${dropped.length === 1 ? "" : "s"} could not be included, ` +
1134
+ `so nothing was saved: ${shown}${rest}. ` +
1135
+ (excluded
1136
+ ? `At least one is excluded by the project's ignore rules. Change the rules or untick it and try again. `
1137
+ : `Another program may have a file open. Close it or untick the file and try again. `) +
1138
+ `Nothing was changed on your account.` +
1139
+ (details ? ` Details: ${details}` : ""));
967
1140
  }
968
1141
  const sourceBytes = contents.reduce((total, item) => total + item.sourceSize, 0);
969
1142
  const storedBytes = contents.reduce((total, item) => total + item.storedSize, 0);
@@ -988,6 +1161,7 @@ class Uploader {
988
1161
  ? {}
989
1162
  : { expectedHeadVersionId: request.expectedHeadVersionId }),
990
1163
  ...(request.track ? { track: request.track } : {}),
1164
+ ...(request.allowIgnored ? { allowIgnored: true } : {}),
991
1165
  /*
992
1166
  Names this attempt so a retry after a lost connection is answered
993
1167
  with the version already made, rather than making a second one.
@@ -1067,6 +1241,7 @@ class Uploader {
1067
1241
  reusedFiles: reused.length,
1068
1242
  /** Selected, but the service already held the content. */
1069
1243
  alreadyStoredFiles: alreadyOnAccount,
1244
+ telemetry: this.telemetry.snapshot(),
1070
1245
  // A file that kept its old object records the digest of *that* copy,
1071
1246
  // not of the file on disk, so an unticked edit is still pending next
1072
1247
  // time rather than looking as though it had been saved.
@@ -1203,6 +1378,30 @@ class Uploader {
1203
1378
  if (!built)
1204
1379
  return null;
1205
1380
  const packed = this.frameSolidPack(built);
1381
+ if (this.planning) {
1382
+ const objectId = this.rememberObject({
1383
+ sha256: built.sha256,
1384
+ size: built.size,
1385
+ storedSize: built.body.byteLength,
1386
+ storedSha256: built.storedSha256,
1387
+ mediaType: "application/octet-stream",
1388
+ kind: "solid_pack",
1389
+ repositoryRole: "bundle",
1390
+ encoding: "gzip",
1391
+ });
1392
+ const placed = new Map();
1393
+ for (const member of built.members) {
1394
+ placed.set(member.sha256, {
1395
+ packObjectId: objectId,
1396
+ offset: member.offset,
1397
+ length: member.length,
1398
+ storedSize: built.size
1399
+ ? Math.round((member.length / built.size) * built.body.byteLength)
1400
+ : 0,
1401
+ });
1402
+ }
1403
+ return placed;
1404
+ }
1206
1405
  const answer = await this.call(`/v1/repositories/${repositoryId}/objects/pack`, {
1207
1406
  method: "POST",
1208
1407
  contentType: "application/octet-stream",
@@ -1319,6 +1518,17 @@ class Uploader {
1319
1518
  const wireBytes = signatureBytes.byteLength + framed.byteLength;
1320
1519
  if (wireBytes >= solidPackBytes * 0.8)
1321
1520
  return null;
1521
+ if (this.planning) {
1522
+ const objects = new Map();
1523
+ for (const item of items) {
1524
+ const definition = this.definition(item.declaration.sha256, item.body.byteLength, item.declaration.mediaType, item.encoded);
1525
+ objects.set(item.declaration.sha256, {
1526
+ objectId: this.rememberObject(definition),
1527
+ size: definition.storedSize,
1528
+ });
1529
+ }
1530
+ return { objects, sentBytes: wireBytes };
1531
+ }
1322
1532
  const answer = await this.call(`/v1/repositories/${repositoryId}/objects/delta/batch`, {
1323
1533
  method: "POST",
1324
1534
  contentType: "application/octet-stream",
@@ -1404,6 +1614,14 @@ class Uploader {
1404
1614
  const wireBytes = signatureBytes.byteLength + framed.byteLength;
1405
1615
  if (wireBytes >= ordinary.body.byteLength * 0.8)
1406
1616
  return null;
1617
+ if (this.planning) {
1618
+ const definition = this.definition(declaration.sha256, target.byteLength, declaration.mediaType, ordinary);
1619
+ return {
1620
+ objectId: this.rememberObject(definition),
1621
+ storedSize: definition.storedSize,
1622
+ sentBytes: wireBytes,
1623
+ };
1624
+ }
1407
1625
  const answer = await this.call(`/v1/repositories/${repositoryId}/objects/delta`, {
1408
1626
  method: "POST",
1409
1627
  contentType: "application/octet-stream",
@@ -1477,6 +1695,16 @@ class Uploader {
1477
1695
  packed.set(item.encoded.body, at);
1478
1696
  at += item.encoded.body.byteLength;
1479
1697
  }
1698
+ if (this.planning) {
1699
+ for (const item of items) {
1700
+ const definition = this.definition(item.declaration.sha256, item.body.byteLength, item.declaration.mediaType, item.encoded);
1701
+ landed.set(item.declaration.sha256, {
1702
+ objectId: this.rememberObject(definition),
1703
+ size: definition.storedSize,
1704
+ });
1705
+ }
1706
+ return landed;
1707
+ }
1480
1708
  const answer = await this.call(`/v1/repositories/${repositoryId}/objects/batch`, {
1481
1709
  method: "POST",
1482
1710
  contentType: "application/octet-stream",
@@ -1687,6 +1915,16 @@ class Uploader {
1687
1915
  `&logicalSize=${piece.length}` +
1688
1916
  `&storedSha256=${encoded.storedSha256}`
1689
1917
  : `?kind=chunk&role=chunk`;
1918
+ if (this.planning) {
1919
+ const definition = this.definition(digest, piece.length, declaration.mediaType, encoded);
1920
+ note("sent", encoded.body.byteLength);
1921
+ chunks[at] = {
1922
+ objectId: this.rememberObject(definition),
1923
+ sourceSize: piece.length,
1924
+ storedSize: definition.storedSize,
1925
+ };
1926
+ return;
1927
+ }
1690
1928
  const stored = await this.call(`/v1/repositories/${repositoryId}/objects/${digest}${query}`, {
1691
1929
  method: "PUT",
1692
1930
  contentType: "application/octet-stream",
@@ -1728,6 +1966,13 @@ class Uploader {
1728
1966
  `&logicalSize=${body.length}` +
1729
1967
  `&storedSha256=${encoded.storedSha256}`
1730
1968
  : `?kind=chunk&role=chunk`;
1969
+ if (this.planning) {
1970
+ const definition = this.definition(digest, body.length, declaration.mediaType, encoded);
1971
+ return {
1972
+ objectId: this.rememberObject(definition),
1973
+ size: definition.storedSize,
1974
+ };
1975
+ }
1731
1976
  /*
1732
1977
  The answer carries both sizes and they are not the same thing: `size` is
1733
1978
  the file's own length, `storedSize` is what the service actually keeps.
@@ -1768,6 +2013,12 @@ class Uploader {
1768
2013
  this.packingSupported = (await this.serviceFeatures()).includes("solid-packs");
1769
2014
  return this.packingSupported;
1770
2015
  }
2016
+ async microchunkAllowed() {
2017
+ if (this.microchunkSupported !== null)
2018
+ return this.microchunkSupported;
2019
+ this.microchunkSupported = (await this.serviceFeatures()).includes("microchunk-map-v1");
2020
+ return this.microchunkSupported;
2021
+ }
1771
2022
  async deltaVersion() {
1772
2023
  if (this.deltaProtocol !== null)
1773
2024
  return this.deltaProtocol;
@@ -1791,40 +2042,57 @@ class Uploader {
1791
2042
  this.chunkingSupported = (await this.serviceFeatures()).includes("chunked-files");
1792
2043
  return this.chunkingSupported;
1793
2044
  }
1794
- /** What /health says this deployment accepts. Fetched once. */
2045
+ /** What /health says this deployment accepts. One request per uploader. */
2046
+ async serviceCapabilities() {
2047
+ if (this.capabilityRequest)
2048
+ return this.capabilityRequest;
2049
+ this.capabilityRequest = (async () => {
2050
+ try {
2051
+ const response = await fetch(`${this.credentials.origin()}/health`, {
2052
+ headers: { accept: "application/json", ...(0, identify_js_1.clientHeaders)() },
2053
+ signal: AbortSignal.timeout(8000),
2054
+ });
2055
+ if (!response.ok)
2056
+ throw new Error(`health failed (${response.status})`);
2057
+ const body = (await response.json());
2058
+ return {
2059
+ features: body.features ?? [],
2060
+ contentEncodings: body.contentEncodings ?? ["identity"],
2061
+ };
2062
+ }
2063
+ catch {
2064
+ /* Unknown is treated as unsupported: never risk a refused upload. */
2065
+ return { features: [], contentEncodings: ["identity"] };
2066
+ }
2067
+ })();
2068
+ return this.capabilityRequest;
2069
+ }
1795
2070
  async serviceFeatures() {
1796
- try {
1797
- const response = await fetch(`${this.credentials.origin()}/health`, {
1798
- headers: { accept: "application/json", ...(0, identify_js_1.clientHeaders)() },
1799
- signal: AbortSignal.timeout(8000),
1800
- });
1801
- const body = (await response.json());
1802
- return body.features ?? [];
1803
- }
1804
- catch {
1805
- /* Unknown is treated as unsupported: never risk a refused upload. */
1806
- return [];
1807
- }
2071
+ return (await this.serviceCapabilities()).features;
1808
2072
  }
1809
2073
  async gzipAllowed() {
1810
2074
  if (this.gzipSupported !== null)
1811
2075
  return this.gzipSupported;
1812
- try {
1813
- const response = await fetch(`${this.credentials.origin()}/health`, {
1814
- headers: { accept: "application/json", ...(0, identify_js_1.clientHeaders)() },
1815
- signal: AbortSignal.timeout(8000),
1816
- });
1817
- const body = (await response.json());
1818
- this.gzipSupported = Boolean(body.contentEncodings?.includes("gzip"));
1819
- }
1820
- catch {
1821
- /* Unknown is treated as unsupported: never risk a refused upload. */
1822
- this.gzipSupported = false;
1823
- }
2076
+ const body = await this.serviceCapabilities();
2077
+ this.gzipSupported = body.contentEncodings.includes("gzip");
1824
2078
  return this.gzipSupported;
1825
2079
  }
1826
2080
  /** Files past the direct limit go up in parts under an upload session. */
1827
2081
  async putMultipart(repositoryId, declaration, onOffset) {
2082
+ if (this.planning) {
2083
+ const objectId = this.rememberObject({
2084
+ sha256: declaration.sha256,
2085
+ size: declaration.size,
2086
+ storedSize: declaration.size,
2087
+ storedSha256: declaration.sha256,
2088
+ mediaType: declaration.mediaType,
2089
+ kind: "chunk",
2090
+ repositoryRole: "chunk",
2091
+ encoding: "identity",
2092
+ });
2093
+ onOffset(declaration.size);
2094
+ return { objectId, size: declaration.size };
2095
+ }
1828
2096
  const session = await this.call(`/v1/repositories/${repositoryId}/uploads`, {
1829
2097
  method: "POST",
1830
2098
  contentType: "application/json",
@@ -1852,7 +2120,12 @@ class Uploader {
1852
2120
  partNumber += 1;
1853
2121
  onOffset(offset);
1854
2122
  }
1855
- return await this.call(`/v1/uploads/${session.uploadSessionId}/complete`, { method: "POST", contentType: "application/json", body: "{}" });
2123
+ return await this.call(`/v1/uploads/${session.uploadSessionId}/complete`, {
2124
+ method: "POST",
2125
+ contentType: "application/json",
2126
+ body: "{}",
2127
+ retryable: false,
2128
+ });
1856
2129
  }
1857
2130
  catch (error) {
1858
2131
  // A half-finished session would hold storage forever.
@@ -8,8 +8,9 @@
8
8
  * means adding a new id, never changing the meaning of an existing one.
9
9
  */
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
- exports.DEFAULT_CHUNK_PROFILE = exports.HUGE_CHUNK_PROFILE = exports.LARGE_CHUNK_PROFILE = exports.MEDIUM_CHUNK_PROFILE = exports.LEGACY_CHUNK_PROFILE = void 0;
11
+ exports.MICRO_CHUNK_PROFILE = exports.DEFAULT_CHUNK_PROFILE = exports.HUGE_CHUNK_PROFILE = exports.LARGE_CHUNK_PROFILE = exports.MEDIUM_CHUNK_PROFILE = exports.LEGACY_CHUNK_PROFILE = void 0;
12
12
  exports.profileForFileSize = profileForFileSize;
13
+ exports.microchunkProfileForFileSize = microchunkProfileForFileSize;
13
14
  exports.cutPoints = cutPoints;
14
15
  exports.chunkBoundaries = chunkBoundaries;
15
16
  exports.manifestChunking = manifestChunking;
@@ -63,6 +64,20 @@ exports.HUGE_CHUNK_PROFILE = Object.freeze({
63
64
  looseMask: 0x003f_ffff,
64
65
  });
65
66
  exports.DEFAULT_CHUNK_PROFILE = exports.HUGE_CHUNK_PROFILE;
67
+ /**
68
+ * Fine-grained large-file transport. It uses the same portable FastCDC
69
+ * boundary function but keeps edit amplification near one MiB instead of
70
+ * four to eight MiB. Existing Versions retain their recorded chunk objects.
71
+ */
72
+ exports.MICRO_CHUNK_PROFILE = Object.freeze({
73
+ id: "fastcdc-v3-micro",
74
+ algorithm: "fastcdc-v2",
75
+ minSize: 256 * KIB,
76
+ targetSize: 1 * MIB,
77
+ maxSize: 4 * MIB,
78
+ strictMask: 0x001f_ffff,
79
+ looseMask: 0x0007_ffff,
80
+ });
66
81
  /** The profile repository uploads use for a file of `size` bytes. */
67
82
  function profileForFileSize(size) {
68
83
  if (!Number.isFinite(size) || size < 0) {
@@ -76,6 +91,11 @@ function profileForFileSize(size) {
76
91
  return exports.LARGE_CHUNK_PROFILE;
77
92
  return exports.HUGE_CHUNK_PROFILE;
78
93
  }
94
+ /** New-write profile when the service advertises the microchunk map. */
95
+ function microchunkProfileForFileSize(size) {
96
+ const legacy = profileForFileSize(size);
97
+ return legacy ? exports.MICRO_CHUNK_PROFILE : null;
98
+ }
79
99
  const GEAR = (() => {
80
100
  const table = new Uint32Array(256);
81
101
  let seed = 0x9e3779b9;
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ /*
3
+ * The storage representation decision is a protocol rule, not a desktop UI
4
+ * preference. Keep the cheap, deterministic part of it in a runtime-neutral
5
+ * module so Desktop, CLI (through Desktop's uploader), Website and the Worker
6
+ * make the same decision for the same object.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.WORTHWHILE_COMPRESSION_RATIO = exports.SMALLEST_WORTH_COMPRESSING = void 0;
10
+ exports.looksCompressed = looksCompressed;
11
+ exports.shouldTryGzip = shouldTryGzip;
12
+ exports.gzipIsWorthKeeping = gzipIsWorthKeeping;
13
+ const ALREADY_COMPRESSED = new Set([
14
+ ".7z", ".aac", ".avi", ".br", ".bz2", ".cbx", ".docx", ".flac", ".gif",
15
+ ".gz", ".heic", ".jpeg", ".jpg", ".jxl", ".m4a", ".m4v", ".mkv", ".mov",
16
+ ".mp3", ".mp4", ".odp", ".ods", ".odt", ".ogg", ".opus", ".pdf", ".png",
17
+ ".pptx", ".rar", ".safetensors", ".webm", ".webp", ".xlsx", ".xz", ".zip",
18
+ ".zst",
19
+ ]);
20
+ exports.SMALLEST_WORTH_COMPRESSING = 4 * 1024;
21
+ exports.WORTHWHILE_COMPRESSION_RATIO = 0.95;
22
+ function looksCompressed(relativePath) {
23
+ const dot = relativePath.lastIndexOf(".");
24
+ if (dot < 0)
25
+ return false;
26
+ return ALREADY_COMPRESSED.has(relativePath.slice(dot).toLowerCase());
27
+ }
28
+ function shouldTryGzip(relativePath, sourceBytes, allowGzip) {
29
+ return (allowGzip &&
30
+ sourceBytes >= exports.SMALLEST_WORTH_COMPRESSING &&
31
+ !looksCompressed(relativePath));
32
+ }
33
+ function gzipIsWorthKeeping(sourceBytes, compressedBytes) {
34
+ return compressedBytes < sourceBytes * exports.WORTHWHILE_COMPRESSION_RATIO;
35
+ }
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RepositoryTelemetry = void 0;
4
+ const byteLength = (body) => typeof body === "string" ? new TextEncoder().encode(body).byteLength : body?.byteLength ?? 0;
5
+ class RepositoryTelemetry {
6
+ started = performance.now();
7
+ requests = 0;
8
+ retries = 0;
9
+ requestBodyBytes = 0;
10
+ responseBodyBytes = 0;
11
+ peakMemoryBytes = null;
12
+ routes = new Map();
13
+ retry() {
14
+ this.retries += 1;
15
+ }
16
+ request(route, body, responseBytes, elapsedMs) {
17
+ this.requestBytes(route, byteLength(body), responseBytes, elapsedMs);
18
+ }
19
+ requestBytes(route, sentBytes, responseBytes, elapsedMs) {
20
+ this.requests += 1;
21
+ this.requestBodyBytes += sentBytes;
22
+ this.responseBodyBytes += responseBytes;
23
+ const prior = this.routes.get(route) ?? { requests: 0, sentBytes: 0, receivedBytes: 0, elapsedMs: 0 };
24
+ prior.requests += 1;
25
+ prior.sentBytes += sentBytes;
26
+ prior.receivedBytes += responseBytes;
27
+ prior.elapsedMs += elapsedMs;
28
+ this.routes.set(route, prior);
29
+ }
30
+ memory(bytes) {
31
+ if (bytes === null || bytes === undefined || !Number.isFinite(bytes) || bytes < 0)
32
+ return;
33
+ this.peakMemoryBytes = Math.max(this.peakMemoryBytes ?? 0, bytes);
34
+ }
35
+ snapshot() {
36
+ return {
37
+ elapsedMs: performance.now() - this.started,
38
+ requests: this.requests,
39
+ retries: this.retries,
40
+ requestBodyBytes: this.requestBodyBytes,
41
+ responseBodyBytes: this.responseBodyBytes,
42
+ peakMemoryBytes: this.peakMemoryBytes,
43
+ routes: Object.fromEntries([...this.routes.entries()].sort(([left], [right]) => left.localeCompare(right))),
44
+ };
45
+ }
46
+ }
47
+ exports.RepositoryTelemetry = RepositoryTelemetry;