@coderook/cli 0.22.1 → 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.
@@ -265,7 +265,18 @@ async function commandProjects() {
265
265
  }
266
266
  async function commandStatus(parsed) {
267
267
  const folder = folderFor(parsed);
268
- const { link, baseline } = await reconcile(folder);
268
+ let link;
269
+ let baseline;
270
+ try {
271
+ ({ link, baseline } = await reconcile(folder));
272
+ }
273
+ catch (error) {
274
+ link = await (0, config_js_1.readLink)(folder);
275
+ if (!link?.manifest)
276
+ throw error;
277
+ baseline = new Map(Object.entries(link.local ?? link.manifest));
278
+ console.log(dim("Offline — comparing with the last Version cached for this folder."));
279
+ }
269
280
  const rules = await (0, worktree_js_1.readRules)(folder);
270
281
  const files = await (0, worktree_js_1.changedFiles)(folder, rules, baseline);
271
282
  console.log(bold(node_path_1.default.basename(folder)) + dim(` ${folder}`));
@@ -463,12 +474,6 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
463
474
  }
464
475
  console.log(dim("Sending them anyway, because --allow-secrets was given."));
465
476
  }
466
- if (hasFlag(parsed, "dry-run", "n")) {
467
- console.log(`${files.length} file${files.length === 1 ? "" : "s"} would be sent:`);
468
- for (const file of files)
469
- console.log(` ${file.path}`);
470
- return 0;
471
- }
472
477
  if (added) {
473
478
  console.log(`${accent("Added an MIT licence")}, so other people may use this.`);
474
479
  console.log(dim(` Change it with `) +
@@ -480,42 +485,46 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
480
485
  }
481
486
  const line = progressLine();
482
487
  const uploader = new upload_js_1.Uploader(config_js_1.credentials);
488
+ const uploadRequest = {
489
+ localPath: folder,
490
+ include: files.map((file) => file.path),
491
+ deletions: files.filter((file) => file.deleted).map((file) => file.path),
492
+ message,
493
+ projectName: link?.slug ?? node_path_1.default.basename(folder),
494
+ repositoryId: link?.repositoryId ?? null,
495
+ // Every current CLI publish states its ancestry. A brand-new project is
496
+ // explicitly based on an empty Track; a linked folder names the immutable
497
+ // Version it was last reconciled with.
498
+ baseVersionId: link?.baseVersionId ?? null,
499
+ track: flagText(parsed, "track") ?? (await (0, track_commands_js_1.trackFor)(folder)),
500
+ ...(link?.baseVersionId
501
+ ? { expectedHeadVersionId: link.baseVersionId }
502
+ : {}),
503
+ ...(link ? { known: link.local ?? link.manifest ?? {} } : {}),
504
+ };
483
505
  let result;
484
506
  try {
485
- result = await uploader.run({
486
- localPath: folder,
487
- include: files.map((file) => file.path),
488
- deletions: files.filter((file) => file.deleted).map((file) => file.path),
489
- message,
490
- projectName: link?.slug ?? node_path_1.default.basename(folder),
491
- repositoryId: link?.repositoryId ?? null,
492
- // Every current CLI publish states its ancestry. A brand-new project
493
- // is explicitly based on an empty Track; a linked folder names the
494
- // immutable Version it was last reconciled with.
495
- baseVersionId: link?.baseVersionId ?? null,
496
- /*
497
- The line this save belongs on: named on the command, or whichever one
498
- the folder is set to. Sent by name rather than resolved here, because
499
- the service owns the rules about which names exist and which may be
500
- written to a rule enforced in two places is one that will disagree
501
- with itself.
502
- */
503
- track: flagText(parsed, "track") ?? (await (0, track_commands_js_1.trackFor)(folder)),
504
- /*
505
- Only claimed when this folder has actually been reconciled with a
506
- known version. A folder linked before versions were recorded says
507
- nothing rather than guessing, and publishes as it always did.
508
- */
509
- ...(link?.baseVersionId
510
- ? { expectedHeadVersionId: link.baseVersionId }
511
- : {}),
512
- /*
513
- What this folder believed the project held. It is how the service
514
- tells a file this person deleted from one they never had, and
515
- without it somebody else's work disappears at the next save.
516
- */
517
- ...(link ? { known: link.local ?? link.manifest ?? {} } : {}),
518
- }, (progress) => {
507
+ const plan = await uploader.plan(uploadRequest, (progress) => {
508
+ line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
509
+ `${"plan".padEnd(7)} ${progress.files}/${progress.totalFiles} ` +
510
+ dim(progress.path.slice(-40)));
511
+ });
512
+ done(line);
513
+ console.log(`${accent("Upload plan")} ${bytes(plan.sourceBytes)} selected → ` +
514
+ `${bytes(plan.compactedBytes)} compacted; ${bytes(plan.chargeableBytes)} new storage.`);
515
+ console.log(plan.allowance.exempt
516
+ ? dim("Annual plan: the monthly staged upload allowance does not apply.")
517
+ : dim(`Monthly stage ${plan.allowance.stage}: ${plan.allowance.unlockedPercent}% unlocked, ` +
518
+ `${bytes(plan.allowance.remainingBytes)} remains after this reservation.`));
519
+ if (hasFlag(parsed, "dry-run", "n")) {
520
+ await uploader.cancelPlan(plan, true);
521
+ console.log(`${files.length} file${files.length === 1 ? "" : "s"} would be sent or reused:`);
522
+ for (const file of files)
523
+ console.log(` ${file.path}`);
524
+ console.log("Nothing was uploaded and no Version was created.");
525
+ return 0;
526
+ }
527
+ result = await uploader.execute(uploadRequest, plan, (progress) => {
519
528
  line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
520
529
  `${progress.stage.padEnd(7)} ${progress.files}/${progress.totalFiles} ` +
521
530
  dim(progress.path.slice(-40)));
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.queueOfflineCandidate = queueOfflineCandidate;
7
+ exports.readOfflineCandidate = readOfflineCandidate;
8
+ const promises_1 = require("node:fs/promises");
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ async function queueOfflineCandidate(folder, candidate) {
11
+ const directory = node_path_1.default.join(folder, ".coderook", "outbox");
12
+ await (0, promises_1.mkdir)(directory, { recursive: true });
13
+ const body = {
14
+ format: "coderook-offline-candidate-v1",
15
+ createdAt: new Date().toISOString(),
16
+ ...candidate,
17
+ };
18
+ const name = `${Date.now()}-${crypto.randomUUID()}.json`;
19
+ const destination = node_path_1.default.join(directory, name);
20
+ await (0, promises_1.writeFile)(destination, JSON.stringify(body, null, 2), { encoding: "utf8", mode: 0o600 });
21
+ return destination;
22
+ }
23
+ async function readOfflineCandidate(file) {
24
+ const value = JSON.parse(await (0, promises_1.readFile)(file, "utf8"));
25
+ if (value.format !== "coderook-offline-candidate-v1")
26
+ throw new Error("Unsupported offline candidate");
27
+ return value;
28
+ }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.looksCompressed = looksCompressed;
3
+ exports.looksCompressed = void 0;
4
4
  exports.encodeForUpload = encodeForUpload;
5
5
  /**
6
6
  * Compressing an object before it is sent.
@@ -29,6 +29,9 @@ exports.encodeForUpload = encodeForUpload;
29
29
  const node_crypto_1 = require("node:crypto");
30
30
  const node_zlib_1 = require("node:zlib");
31
31
  const node_util_1 = require("node:util");
32
+ const compression_policy_1 = require("../shared/compression_policy");
33
+ var compression_policy_2 = require("../shared/compression_policy");
34
+ Object.defineProperty(exports, "looksCompressed", { enumerable: true, get: function () { return compression_policy_2.looksCompressed; } });
32
35
  const deflate = (0, node_util_1.promisify)(node_zlib_1.gzip);
33
36
  /*
34
37
  Extensions whose contents are already compressed. Matching on the name is a
@@ -36,30 +39,6 @@ const deflate = (0, node_util_1.promisify)(node_zlib_1.gzip);
36
39
  is measured rather than guessed, so a wrong entry here costs a little time
37
40
  and never a wrong result.
38
41
  */
39
- const ALREADY_COMPRESSED = new Set([
40
- ".7z", ".aac", ".avi", ".br", ".bz2", ".cbx", ".docx", ".flac", ".gif",
41
- ".gz", ".heic", ".jpeg", ".jpg", ".jxl", ".m4a", ".m4v", ".mkv", ".mov",
42
- ".mp3", ".mp4", ".odp", ".ods", ".odt", ".ogg", ".opus", ".pdf", ".png",
43
- ".pptx", ".rar", ".safetensors", ".webm", ".webp", ".xlsx", ".xz", ".zip",
44
- ".zst",
45
- ]);
46
- /**
47
- * Below this, compression is not worth the round trip through zlib — the
48
- * saving is measured in bytes and every file pays the CPU.
49
- */
50
- const SMALLEST_WORTH_TRYING = 4 * 1024;
51
- /**
52
- * Keep the compressed form only if it saved something worth having. A file
53
- * that shrinks by two percent is not worth the service decompressing it on
54
- * every read for the rest of its life.
55
- */
56
- const WORTHWHILE = 0.95;
57
- function looksCompressed(relativePath) {
58
- const dot = relativePath.lastIndexOf(".");
59
- if (dot < 0)
60
- return false;
61
- return ALREADY_COMPRESSED.has(relativePath.slice(dot).toLowerCase());
62
- }
63
42
  /**
64
43
  * Decide how one object should travel.
65
44
  *
@@ -73,15 +52,11 @@ async function encodeForUpload(original, relativePath, allowGzip) {
73
52
  encoding: "identity",
74
53
  storedSha256: "",
75
54
  };
76
- if (!allowGzip)
77
- return plain;
78
- if (original.byteLength < SMALLEST_WORTH_TRYING)
79
- return plain;
80
- if (looksCompressed(relativePath))
55
+ if (!(0, compression_policy_1.shouldTryGzip)(relativePath, original.byteLength, allowGzip))
81
56
  return plain;
82
57
  try {
83
58
  const packed = await deflate(original, { level: 6 });
84
- if (packed.byteLength >= original.byteLength * WORTHWHILE)
59
+ if (!(0, compression_policy_1.gzipIsWorthKeeping)(original.byteLength, packed.byteLength))
85
60
  return plain;
86
61
  return {
87
62
  body: new Uint8Array(packed),
@@ -174,8 +174,39 @@ class Downloader {
174
174
  }
175
175
  return response;
176
176
  }
177
+ /**
178
+ * Read a complete JSON response under the same retry rule as the request.
179
+ *
180
+ * `fetch()` resolves as soon as the headers arrive. A connection can still
181
+ * disappear while `.json()` is consuming the body; treating the response
182
+ * object as success meant a large file list failed outside the retry loop.
183
+ * These routes are reads, so repeating the whole request is safe and is the
184
+ * only way to replace a truncated body.
185
+ */
186
+ async json(route) {
187
+ let wait = retry_js_1.RETRY_FIRST_WAIT_MS;
188
+ for (let attempt = 1;; attempt += 1) {
189
+ this.check();
190
+ try {
191
+ return await (await this.attempt(route)).json();
192
+ }
193
+ catch (error) {
194
+ if (error instanceof DownloadCancelled)
195
+ throw error;
196
+ if (attempt >= retry_js_1.RETRY_ATTEMPTS || !(0, retry_js_1.worthRetrying)(error))
197
+ throw error;
198
+ try {
199
+ await (0, retry_js_1.pauseFor)(this.controller.signal, wait);
200
+ }
201
+ catch {
202
+ throw new DownloadCancelled();
203
+ }
204
+ wait *= 2;
205
+ }
206
+ }
207
+ }
177
208
  async versions(repositoryId) {
178
- const body = (await (await this.request(`/v1/repositories/${repositoryId}/versions`)).json());
209
+ const body = await this.json(`/v1/repositories/${repositoryId}/versions`);
179
210
  return (body.versions ?? []).map((row) => ({
180
211
  id: String(row.id ?? ""),
181
212
  sequence: Number(row.sequence ?? 0),
@@ -187,13 +218,26 @@ class Downloader {
187
218
  }));
188
219
  }
189
220
  async files(repositoryId, versionId) {
190
- const body = (await (await this.request(`/v1/repositories/${repositoryId}/versions/${versionId}/files`)).json());
221
+ const body = await this.json(`/v1/repositories/${repositoryId}/versions/${versionId}/files`);
191
222
  return (body.files ?? []).map((row) => {
192
223
  const pieces = Array.isArray(row.chunks) ? row.chunks : null;
224
+ const packed = row.pack && typeof row.pack === "object"
225
+ ? row.pack
226
+ : null;
193
227
  return {
194
228
  path: String(row.path ?? ""),
229
+ ...(row.objectId ? { objectId: String(row.objectId) } : {}),
195
230
  sha256: String(row.sha256 ?? ""),
196
231
  sourceSize: Number(row.sourceSize ?? 0),
232
+ ...(packed?.objectId
233
+ ? {
234
+ pack: {
235
+ objectId: String(packed.objectId),
236
+ offset: Number(packed.offset ?? 0),
237
+ length: Number(packed.length ?? 0),
238
+ },
239
+ }
240
+ : {}),
197
241
  ...(pieces && pieces.length
198
242
  ? {
199
243
  chunks: pieces.map((piece) => {
@@ -240,6 +284,92 @@ class Downloader {
240
284
  await (0, promises_1.mkdir)(node_path_1.default.dirname(target), { recursive: true });
241
285
  return this.fetchInto(repositoryId, versionId, file, target);
242
286
  }
287
+ /**
288
+ * Reconstruct and verify every file in a Version without materialising the
289
+ * whole project at once. Solid packs are fetched once and large files use a
290
+ * single reusable scratch path, so the disk requirement is bounded by the
291
+ * largest individual file rather than the complete snapshot.
292
+ */
293
+ async verify(repositoryId, versionId, scratch, report) {
294
+ const files = await this.files(repositoryId, versionId);
295
+ if (!files.length)
296
+ throw new Error("That version has no files");
297
+ const totalBytes = files.reduce((total, file) => total + file.sourceSize, 0);
298
+ const packed = new Map();
299
+ for (const file of files) {
300
+ const id = file.pack?.objectId;
301
+ if (!id)
302
+ continue;
303
+ const group = packed.get(id) ?? [];
304
+ group.push(file);
305
+ packed.set(id, group);
306
+ }
307
+ await (0, promises_1.rm)(scratch, { recursive: true, force: true });
308
+ await (0, promises_1.mkdir)(scratch, { recursive: true });
309
+ const temporary = node_path_1.default.join(scratch, "current-file.partial");
310
+ const restoredPacks = new Set();
311
+ const manifest = {};
312
+ let written = 0;
313
+ let bytes = 0;
314
+ const progress = (filePath) => report({
315
+ files: written,
316
+ totalFiles: files.length,
317
+ bytes,
318
+ totalBytes,
319
+ path: filePath,
320
+ percent: underway(bytes, totalBytes),
321
+ });
322
+ try {
323
+ for (const file of files) {
324
+ this.check();
325
+ progress(file.path);
326
+ const packId = file.pack?.objectId;
327
+ if (packId && restoredPacks.has(packId))
328
+ continue;
329
+ if (packId) {
330
+ const reply = await this.request(`/v1/repositories/${repositoryId}/objects/${packId}`);
331
+ const body = Buffer.from(await reply.arrayBuffer());
332
+ const packDigest = (0, node_crypto_1.createHash)("sha256").update(body).digest("hex");
333
+ const expectedPackDigest = reply.headers.get("x-coderook-sha256");
334
+ if (expectedPackDigest && packDigest !== expectedPackDigest) {
335
+ throw new Error("A solid pack did not arrive intact");
336
+ }
337
+ for (const member of packed.get(packId) ?? []) {
338
+ const offset = member.pack.offset;
339
+ const length = member.pack.length;
340
+ if (offset < 0 || length < 0 || offset + length > body.byteLength) {
341
+ throw new Error(`${member.path} points outside its solid pack`);
342
+ }
343
+ const digest = (0, node_crypto_1.createHash)("sha256")
344
+ .update(body.subarray(offset, offset + length))
345
+ .digest("hex");
346
+ if (member.sha256 && digest !== member.sha256) {
347
+ throw new Error(`${member.path} did not arrive intact`);
348
+ }
349
+ manifest[member.path] = digest;
350
+ written += 1;
351
+ bytes += member.sourceSize;
352
+ progress(member.path);
353
+ }
354
+ restoredPacks.add(packId);
355
+ continue;
356
+ }
357
+ const digest = await this.fetchInto(repositoryId, versionId, file, temporary);
358
+ if (file.sha256 && digest !== file.sha256) {
359
+ throw new Error(`${file.path} did not arrive intact`);
360
+ }
361
+ manifest[file.path] = digest;
362
+ written += 1;
363
+ bytes += file.sourceSize;
364
+ await (0, promises_1.rm)(temporary, { force: true });
365
+ }
366
+ progress("Done");
367
+ return { files: written, bytes, manifest };
368
+ }
369
+ finally {
370
+ await (0, promises_1.rm)(scratch, { recursive: true, force: true });
371
+ }
372
+ }
243
373
  async fetchInto(repositoryId, versionId, file, target) {
244
374
  const whole = (0, node_crypto_1.createHash)("sha256");
245
375
  const pieces = file.chunks ?? [];
@@ -269,8 +399,10 @@ class Downloader {
269
399
  }
270
400
  })()
271
401
  : (async function* () {
272
- const reply = await request(`/v1/repositories/${repositoryId}/versions/${versionId}/file` +
273
- `?path=${encodeURIComponent(file.path)}`);
402
+ const reply = await request(file.objectId
403
+ ? `/v1/repositories/${repositoryId}/objects/${file.objectId}`
404
+ : `/v1/repositories/${repositoryId}/versions/${versionId}/file` +
405
+ `?path=${encodeURIComponent(file.path)}`);
274
406
  if (!reply.body)
275
407
  return;
276
408
  for await (const block of node_stream_1.Readable.fromWeb(reply.body)) {
@@ -322,6 +454,16 @@ class Downloader {
322
454
  try {
323
455
  let written = 0;
324
456
  let bytes = 0;
457
+ const packed = new Map();
458
+ for (const file of files) {
459
+ const id = file.pack?.objectId;
460
+ if (!id)
461
+ continue;
462
+ const group = packed.get(id) ?? [];
463
+ group.push(file);
464
+ packed.set(id, group);
465
+ }
466
+ const restoredPacks = new Set();
325
467
  for (const file of files) {
326
468
  this.check();
327
469
  report({
@@ -337,6 +479,60 @@ class Downloader {
337
479
  if (parts.some((part) => part === ".." || part.includes("\0"))) {
338
480
  throw new Error(`That version contains an unsafe path: ${file.path}`);
339
481
  }
482
+ const packId = file.pack?.objectId;
483
+ if (packId && restoredPacks.has(packId))
484
+ continue;
485
+ if (packId) {
486
+ /*
487
+ A solid pack is one compressed object containing many small files.
488
+
489
+ Asking the single-file route for every member made a large restore
490
+ fetch and decompress the same pack thousands of times. The files
491
+ listing already gives us the pack id and each member's exact
492
+ window, so fetch the decoded pack once, verify it, and materialise
493
+ every member before releasing the bounded section-sized buffer.
494
+ */
495
+ const reply = await this.request(`/v1/repositories/${repositoryId}/objects/${packId}`);
496
+ const body = Buffer.from(await reply.arrayBuffer());
497
+ const packDigest = (0, node_crypto_1.createHash)("sha256").update(body).digest("hex");
498
+ const expectedPackDigest = reply.headers.get("x-coderook-sha256");
499
+ if (expectedPackDigest && packDigest !== expectedPackDigest) {
500
+ throw new Error("A solid pack did not arrive intact");
501
+ }
502
+ for (const member of packed.get(packId) ?? []) {
503
+ this.check();
504
+ const memberParts = member.path.split("/").filter(Boolean);
505
+ if (memberParts.some((part) => part === ".." || part.includes("\0"))) {
506
+ throw new Error(`That version contains an unsafe path: ${member.path}`);
507
+ }
508
+ const offset = member.pack.offset;
509
+ const length = member.pack.length;
510
+ if (offset < 0 || length < 0 || offset + length > body.byteLength) {
511
+ throw new Error(`${member.path} points outside its solid pack`);
512
+ }
513
+ const contents = body.subarray(offset, offset + length);
514
+ const digest = (0, node_crypto_1.createHash)("sha256").update(contents).digest("hex");
515
+ if (member.sha256 && digest !== member.sha256) {
516
+ throw new Error(`${member.path} did not arrive intact`);
517
+ }
518
+ const full = node_path_1.default.join(staging, ...memberParts);
519
+ await (0, promises_1.mkdir)(node_path_1.default.dirname(full), { recursive: true });
520
+ await (0, promises_1.writeFile)(full, contents);
521
+ manifest[member.path] = digest;
522
+ written += 1;
523
+ bytes += member.sourceSize;
524
+ report({
525
+ files: written,
526
+ totalFiles: files.length,
527
+ bytes,
528
+ totalBytes,
529
+ path: member.path,
530
+ percent: underway(bytes, totalBytes),
531
+ });
532
+ }
533
+ restoredPacks.add(packId);
534
+ continue;
535
+ }
340
536
  const full = node_path_1.default.join(staging, ...parts);
341
537
  await (0, promises_1.mkdir)(node_path_1.default.dirname(full), { recursive: true });
342
538
  const digest = await this.fetchInto(repositoryId, versionId, file, full);
@@ -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,7 +252,78 @@ 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.
@@ -293,7 +387,23 @@ class Uploader {
293
387
  */
294
388
  const onDisk = new Set(everything.map((file) => file.path));
295
389
  const deletions = new Set(request.deletions ?? []);
390
+ const readFailures = new Map();
296
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
+ }
297
407
  // 1. Measure and hash. This is what makes an unchanged file free.
298
408
  const declarations = [];
299
409
  let totalBytes = 0;
@@ -304,10 +414,11 @@ class Uploader {
304
414
  try {
305
415
  size = (await (0, promises_1.stat)(full)).size;
306
416
  }
307
- catch {
417
+ catch (error) {
308
418
  // Unreadable now, though it was listed a moment ago. Skipping it
309
419
  // would publish a version quietly missing a file the person chose.
310
420
  vanished.push(file.path);
421
+ readFailures.set(file.path, error instanceof Error ? error.message : String(error));
311
422
  continue;
312
423
  }
313
424
  report({
@@ -337,8 +448,9 @@ class Uploader {
337
448
  try {
338
449
  digest = await (0, profile_js_1.timed)("hash files", () => digestOf(full));
339
450
  }
340
- catch {
451
+ catch (error) {
341
452
  vanished.push(file.path);
453
+ readFailures.set(file.path, error instanceof Error ? error.message : String(error));
342
454
  continue;
343
455
  }
344
456
  declarations.push({
@@ -380,6 +492,8 @@ class Uploader {
380
492
  }),
381
493
  });
382
494
  repositoryId = created.id;
495
+ if (this.planning)
496
+ this.planningCreatedRepositoryId = created.id;
383
497
  }
384
498
  catch (error) {
385
499
  // The account already has a project of this name — which happens
@@ -391,6 +505,10 @@ class Uploader {
391
505
  repositoryId = existing;
392
506
  }
393
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();
394
512
  /*
395
513
  Sent in sections, not in one attempt.
396
514
 
@@ -766,7 +884,9 @@ class Uploader {
766
884
  whole: cutting it up would trade one request for several and save
767
885
  nothing.
768
886
  */
769
- 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);
770
890
  if (chunkProfile &&
771
891
  declaration.size >= CHUNK_THRESHOLD &&
772
892
  (await this.chunkingAllowed())) {
@@ -880,6 +1000,41 @@ class Uploader {
880
1000
  }
881
1001
  };
882
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
+ }
883
1038
  // 4. Name the version, which is what makes the upload visible.
884
1039
  this.check();
885
1040
  report({
@@ -952,6 +1107,11 @@ class Uploader {
952
1107
  if (dropped.length) {
953
1108
  const shown = dropped.slice(0, 5).join(", ");
954
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("; ");
955
1115
  /*
956
1116
  Say what to do about it. Refusing is right — a version quietly missing
957
1117
  a file somebody chose is the one failure a backup tool must never have
@@ -960,10 +1120,14 @@ class Uploader {
960
1120
  holds them or unticking them. Almost every case is a file another
961
1121
  program has open, so that is what it says.
962
1122
  */
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.`);
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}` : ""));
967
1131
  }
968
1132
  const sourceBytes = contents.reduce((total, item) => total + item.sourceSize, 0);
969
1133
  const storedBytes = contents.reduce((total, item) => total + item.storedSize, 0);
@@ -1067,6 +1231,7 @@ class Uploader {
1067
1231
  reusedFiles: reused.length,
1068
1232
  /** Selected, but the service already held the content. */
1069
1233
  alreadyStoredFiles: alreadyOnAccount,
1234
+ telemetry: this.telemetry.snapshot(),
1070
1235
  // A file that kept its old object records the digest of *that* copy,
1071
1236
  // not of the file on disk, so an unticked edit is still pending next
1072
1237
  // time rather than looking as though it had been saved.
@@ -1203,6 +1368,30 @@ class Uploader {
1203
1368
  if (!built)
1204
1369
  return null;
1205
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
+ }
1206
1395
  const answer = await this.call(`/v1/repositories/${repositoryId}/objects/pack`, {
1207
1396
  method: "POST",
1208
1397
  contentType: "application/octet-stream",
@@ -1319,6 +1508,17 @@ class Uploader {
1319
1508
  const wireBytes = signatureBytes.byteLength + framed.byteLength;
1320
1509
  if (wireBytes >= solidPackBytes * 0.8)
1321
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
+ }
1322
1522
  const answer = await this.call(`/v1/repositories/${repositoryId}/objects/delta/batch`, {
1323
1523
  method: "POST",
1324
1524
  contentType: "application/octet-stream",
@@ -1404,6 +1604,14 @@ class Uploader {
1404
1604
  const wireBytes = signatureBytes.byteLength + framed.byteLength;
1405
1605
  if (wireBytes >= ordinary.body.byteLength * 0.8)
1406
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
+ }
1407
1615
  const answer = await this.call(`/v1/repositories/${repositoryId}/objects/delta`, {
1408
1616
  method: "POST",
1409
1617
  contentType: "application/octet-stream",
@@ -1477,6 +1685,16 @@ class Uploader {
1477
1685
  packed.set(item.encoded.body, at);
1478
1686
  at += item.encoded.body.byteLength;
1479
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
+ }
1480
1698
  const answer = await this.call(`/v1/repositories/${repositoryId}/objects/batch`, {
1481
1699
  method: "POST",
1482
1700
  contentType: "application/octet-stream",
@@ -1687,6 +1905,16 @@ class Uploader {
1687
1905
  `&logicalSize=${piece.length}` +
1688
1906
  `&storedSha256=${encoded.storedSha256}`
1689
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
+ }
1690
1918
  const stored = await this.call(`/v1/repositories/${repositoryId}/objects/${digest}${query}`, {
1691
1919
  method: "PUT",
1692
1920
  contentType: "application/octet-stream",
@@ -1728,6 +1956,13 @@ class Uploader {
1728
1956
  `&logicalSize=${body.length}` +
1729
1957
  `&storedSha256=${encoded.storedSha256}`
1730
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
+ }
1731
1966
  /*
1732
1967
  The answer carries both sizes and they are not the same thing: `size` is
1733
1968
  the file's own length, `storedSize` is what the service actually keeps.
@@ -1768,6 +2003,12 @@ class Uploader {
1768
2003
  this.packingSupported = (await this.serviceFeatures()).includes("solid-packs");
1769
2004
  return this.packingSupported;
1770
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
+ }
1771
2012
  async deltaVersion() {
1772
2013
  if (this.deltaProtocol !== null)
1773
2014
  return this.deltaProtocol;
@@ -1791,40 +2032,57 @@ class Uploader {
1791
2032
  this.chunkingSupported = (await this.serviceFeatures()).includes("chunked-files");
1792
2033
  return this.chunkingSupported;
1793
2034
  }
1794
- /** 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
+ }
1795
2060
  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
- }
2061
+ return (await this.serviceCapabilities()).features;
1808
2062
  }
1809
2063
  async gzipAllowed() {
1810
2064
  if (this.gzipSupported !== null)
1811
2065
  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
- }
2066
+ const body = await this.serviceCapabilities();
2067
+ this.gzipSupported = body.contentEncodings.includes("gzip");
1824
2068
  return this.gzipSupported;
1825
2069
  }
1826
2070
  /** Files past the direct limit go up in parts under an upload session. */
1827
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
+ }
1828
2086
  const session = await this.call(`/v1/repositories/${repositoryId}/uploads`, {
1829
2087
  method: "POST",
1830
2088
  contentType: "application/json",
@@ -1852,7 +2110,12 @@ class Uploader {
1852
2110
  partNumber += 1;
1853
2111
  onOffset(offset);
1854
2112
  }
1855
- 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
+ });
1856
2119
  }
1857
2120
  catch (error) {
1858
2121
  // 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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.22.1",
3
+ "version": "0.22.2",
4
4
  "description": "CodeRook from the command line, on any operating system",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "homepage": "https://coderook.com",