@coderook/cli 0.19.0 → 0.21.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.
@@ -2,7 +2,7 @@
2
2
  "name": "coderook",
3
3
  "displayName": "CodeRook",
4
4
  "description": "Save, browse and restore whole-snapshot versions of a project on CodeRook, from Claude Code.",
5
- "version": "0.19.0",
5
+ "version": "0.21.0",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -167,11 +167,15 @@ async function reconcile(folder, options = {}) {
167
167
  folder that has never been reconciled — or was linked before versions
168
168
  were recorded — adopts the newest as its starting point.
169
169
  */
170
- const known = link?.versionId && link.manifest ? link : null;
171
- const behind = known && known.versionId !== latest.id
170
+ const known = link?.baseVersionId && link.manifest ? link : null;
171
+ const behind = known && known.baseVersionId !== latest.id
172
172
  ? { sequence: latest.sequence, id: latest.id }
173
173
  : null;
174
174
  if (known) {
175
+ if (known.observedHeadVersionId !== latest.id) {
176
+ known.observedHeadVersionId = latest.id;
177
+ await (0, config_js_1.writeLink)(folder, known);
178
+ }
175
179
  if (behind && !options.quiet) {
176
180
  console.log(dim(`The account is on v${behind.sequence}; this folder is based on v${known.sequence}.`));
177
181
  }
@@ -196,6 +200,8 @@ async function reconcile(folder, options = {}) {
196
200
  slug: project.slug,
197
201
  sequence: latest.sequence,
198
202
  versionId: latest.id,
203
+ baseVersionId: latest.id,
204
+ observedHeadVersionId: latest.id,
199
205
  // Adopted whole, so the folder is taken to hold what the version holds.
200
206
  local: Object.fromEntries(baseline),
201
207
  manifest: Object.fromEntries(baseline),
@@ -479,9 +485,14 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
479
485
  result = await uploader.run({
480
486
  localPath: folder,
481
487
  include: files.map((file) => file.path),
488
+ deletions: files.filter((file) => file.deleted).map((file) => file.path),
482
489
  message,
483
490
  projectName: link?.slug ?? node_path_1.default.basename(folder),
484
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,
485
496
  /*
486
497
  The line this save belongs on: named on the command, or whichever one
487
498
  the folder is set to. Sent by name rather than resolved here, because
@@ -495,7 +506,9 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
495
506
  known version. A folder linked before versions were recorded says
496
507
  nothing rather than guessing, and publishes as it always did.
497
508
  */
498
- ...(link?.versionId ? { expectedHeadVersionId: link.versionId } : {}),
509
+ ...(link?.baseVersionId
510
+ ? { expectedHeadVersionId: link.baseVersionId }
511
+ : {}),
499
512
  /*
500
513
  What this folder believed the project held. It is how the service
501
514
  tells a file this person deleted from one they never had, and
@@ -570,6 +583,8 @@ The connection failed: ${text}`));
570
583
  sequence: result.sequence,
571
584
  // What this folder is now working from, so the next submit can say so.
572
585
  versionId: result.versionId,
586
+ baseVersionId: result.versionId,
587
+ observedHeadVersionId: result.versionId,
573
588
  local: result.local,
574
589
  manifest: result.manifest,
575
590
  });
@@ -795,6 +810,8 @@ held, mode = "replace") {
795
810
  slug,
796
811
  sequence: latest.sequence,
797
812
  versionId: latest.id,
813
+ baseVersionId: latest.id,
814
+ observedHeadVersionId: latest.id,
798
815
  // Fetching writes every file, so the folder now holds what the
799
816
  // version holds and the two agree again.
800
817
  local: result.manifest,
@@ -143,13 +143,23 @@ async function readLinks() {
143
143
  }
144
144
  async function readLink(localPath) {
145
145
  const links = await readLinks();
146
- return links[keyFor(localPath)] ?? links[legacyKeyFor(localPath)] ?? null;
146
+ const link = links[keyFor(localPath)] ?? links[legacyKeyFor(localPath)] ?? null;
147
+ if (!link)
148
+ return null;
149
+ return {
150
+ ...link,
151
+ baseVersionId: link.baseVersionId ?? link.versionId,
152
+ };
147
153
  }
148
154
  async function writeLink(localPath, link) {
149
155
  const links = await readLinks();
150
156
  // A folder recorded under the old key moves to the new one rather than
151
157
  // being left behind as a second connection to the same place.
152
158
  delete links[legacyKeyFor(localPath)];
153
- links[keyFor(localPath)] = link;
159
+ const baseVersionId = link.baseVersionId ?? link.versionId;
160
+ links[keyFor(localPath)] = {
161
+ ...link,
162
+ ...(baseVersionId ? { baseVersionId, versionId: baseVersionId } : {}),
163
+ };
154
164
  await writePrivate(linksFile(), JSON.stringify(links, null, 2));
155
165
  }
@@ -3,8 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.FOOTER_MAGIC = exports.MANIFEST_MAGIC = exports.CHUNK_MAGIC = exports.HEADER_MAGIC = void 0;
7
- exports.cutPoints = cutPoints;
6
+ exports.FOOTER_MAGIC = exports.MANIFEST_MAGIC = exports.CHUNK_MAGIC = exports.HEADER_MAGIC = exports.cutPoints = void 0;
8
7
  exports.packBundle = packBundle;
9
8
  exports.readManifest = readManifest;
10
9
  exports.unpackBundle = unpackBundle;
@@ -32,6 +31,9 @@ const promises_1 = require("node:fs/promises");
32
31
  const node_path_1 = __importDefault(require("node:path"));
33
32
  const node_zlib_1 = require("node:zlib");
34
33
  const download_js_1 = require("./download.js");
34
+ const chunking_js_1 = require("../shared/chunking.js");
35
+ var chunking_js_2 = require("../shared/chunking.js");
36
+ Object.defineProperty(exports, "cutPoints", { enumerable: true, get: function () { return chunking_js_2.cutPoints; } });
35
37
  exports.HEADER_MAGIC = "CBX1";
36
38
  exports.CHUNK_MAGIC = "CHNK";
37
39
  exports.MANIFEST_MAGIC = "MANF";
@@ -48,9 +50,6 @@ const CODEC_ZSTD = 1;
48
50
  const ZSTD_LEVEL = 3;
49
51
  const MIN_COMPRESSION_SAVING = 0.02;
50
52
  const READ_SIZE = 8 * 1024 * 1024;
51
- const MIN_CHUNK_SIZE = 2 * 1024 * 1024;
52
- const AVG_CHUNK_SIZE = 8 * 1024 * 1024;
53
- const MAX_CHUNK_SIZE = 32 * 1024 * 1024;
54
53
  const zstd = (raw) => (0, node_zlib_1.zstdCompressSync)(raw, {
55
54
  params: { [node_zlib_1.constants.ZSTD_c_compressionLevel]: ZSTD_LEVEL },
56
55
  });
@@ -64,36 +63,6 @@ function sha256(data) {
64
63
  * the `fastcdc` package; cut positions do not have to agree between writers
65
64
  * for an archive to be readable, because the manifest records the chunk list.
66
65
  */
67
- const GEAR = (() => {
68
- const table = new Uint32Array(256);
69
- let seed = 0x9e3779b9;
70
- for (let index = 0; index < 256; index += 1) {
71
- seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
72
- table[index] = seed;
73
- }
74
- return table;
75
- })();
76
- const NORMAL_MASK = 0x0003_5000;
77
- const SMALL_MASK = 0x0003_5403;
78
- /** Cut positions inside `buffer`; the tail is left for the next read. */
79
- function cutPoints(buffer) {
80
- const cuts = [];
81
- let from = 0;
82
- let hash = 0;
83
- for (let at = 0; at < buffer.length; at += 1) {
84
- hash = ((hash << 1) + GEAR[buffer[at]]) >>> 0;
85
- const length = at - from + 1;
86
- if (length < MIN_CHUNK_SIZE)
87
- continue;
88
- const mask = length < AVG_CHUNK_SIZE ? SMALL_MASK : NORMAL_MASK;
89
- if (length >= MAX_CHUNK_SIZE || (hash & mask) === 0) {
90
- cuts.push(at + 1);
91
- from = at + 1;
92
- hash = 0;
93
- }
94
- }
95
- return cuts;
96
- }
97
66
  function chunkFrame(raw) {
98
67
  const rawDigest = (0, node_crypto_1.createHash)("sha256").update(raw).digest();
99
68
  let codec = CODEC_NONE;
@@ -199,7 +168,7 @@ async function packBundle(request, report) {
199
168
  whole.update(block);
200
169
  pending = pending.length ? Buffer.concat([pending, block]) : block;
201
170
  let from = 0;
202
- for (const cut of cutPoints(pending)) {
171
+ for (const cut of (0, chunking_js_1.cutPoints)(pending)) {
203
172
  await emit(pending.subarray(from, cut));
204
173
  from = cut;
205
174
  }
@@ -225,10 +194,7 @@ async function packBundle(request, report) {
225
194
  source_kind: request.sourceKind ?? "directory",
226
195
  created_unix_ns: Date.now() * 1e6,
227
196
  chunking: {
228
- algorithm: "gear-streaming",
229
- min_size: MIN_CHUNK_SIZE,
230
- avg_size: AVG_CHUNK_SIZE,
231
- max_size: MAX_CHUNK_SIZE,
197
+ ...(0, chunking_js_1.manifestChunking)(chunking_js_1.DEFAULT_CHUNK_PROFILE),
232
198
  read_size: READ_SIZE,
233
199
  small_file_packing: { enabled: false },
234
200
  },
@@ -37,8 +37,39 @@ exports.describePlan = describePlan;
37
37
  * session. Naming them here means the plan can be inspected before a byte
38
38
  * moves.
39
39
  */
40
- exports.PACKABLE_LIMIT = 64 * 1024;
41
- exports.CHUNK_THRESHOLD = 16 * 1024 * 1024;
40
+ /*
41
+ Below this a file travels with others in one request rather than alone.
42
+
43
+ Measured rather than guessed, twice. The first number was sixty-four
44
+ kilobytes, taken from a source folder where ninety-six percent of files were
45
+ under it. A four and a half gigabyte game then showed the opposite shape:
46
+ eight hundred files of three hundred kilobytes per section, each just above
47
+ that line and so each paying its own round trip.
48
+
49
+ What the game measured is the reason for the number. Every request costs
50
+ about six hundred and thirty milliseconds before any bytes move, and the
51
+ lane carries roughly 0.79 MB/s. So a three hundred kilobyte file spends
52
+ sixty percent of its time on overhead and a seven megabyte file spends
53
+ seven. The line belongs where the overhead stops dominating, which is a few
54
+ megabytes and not a few kilobytes.
55
+
56
+ Four megabytes was the first attempt and it overshot. Measured on the same
57
+ game twice: four hundred kilobyte files went from 1.80 to 3.24 MB/s when
58
+ batched, while 1.3 megabyte files went from 4.43 down to 3.88 — because
59
+ three concurrent batches move less than six concurrent requests once a file
60
+ is large enough that transfer, not overhead, dominates. The crossover is
61
+ around a megabyte.
62
+
63
+ It lives here, and the uploader imports it, because for a while it did not:
64
+ the uploader had measured its way to a megabyte while this file still said
65
+ sixty-four kilobytes, and sections were therefore planned by one definition
66
+ of "small" and then sent by another. Nothing broke, which is why it lasted —
67
+ the planner simply grouped files it called ordinary and the uploader batched
68
+ them anyway, and a second filter downstream existed to paper over the
69
+ disagreement.
70
+ */
71
+ exports.PACKABLE_LIMIT = 1024 * 1024;
72
+ exports.CHUNK_THRESHOLD = 8 * 1024 * 1024;
42
73
  exports.DIRECT_LIMIT = 95 * 1024 * 1024;
43
74
  function bandOf(size) {
44
75
  if (size <= exports.PACKABLE_LIMIT)
@@ -21,7 +21,8 @@ const identify_js_1 = require("./identify.js");
21
21
  const worktree_js_1 = require("./worktree.js");
22
22
  const publish_name_js_1 = require("./publish_name.js");
23
23
  const compress_js_1 = require("./compress.js");
24
- const cbx_js_1 = require("./cbx.js");
24
+ const chunking_js_1 = require("../shared/chunking.js");
25
+ const publication_js_1 = require("../shared/publication.js");
25
26
  const profile_js_1 = require("./profile.js");
26
27
  const solid_js_1 = require("./solid.js");
27
28
  const retry_js_1 = require("./retry.js");
@@ -34,7 +35,7 @@ const DIRECT_LIMIT = 95 * 1024 * 1024;
34
35
  chunker's own average chunk is eight megabytes, so a smaller file would
35
36
  usually come out as one chunk anyway.
36
37
  */
37
- const CHUNK_THRESHOLD = 16 * 1024 * 1024;
38
+ const CHUNK_THRESHOLD = 8 * 1024 * 1024;
38
39
  /*
39
40
  How many files are sent at once. Six is the limit browsers settled on per
40
41
  host for the same reason: enough to keep the link busy through the latency
@@ -42,29 +43,12 @@ const CHUNK_THRESHOLD = 16 * 1024 * 1024;
42
43
  */
43
44
  const UPLOAD_LANES = 6;
44
45
  /*
45
- Below this a file travels with others in one request rather than alone.
46
-
47
- Measured rather than guessed, twice. The first number came from a source
48
- folder where ninety-six percent of files were under sixty-four kilobytes.
49
- A four and a half gigabyte game then showed the opposite shape: eight
50
- hundred files of three hundred kilobytes per section, each just above that
51
- line and so each paying its own round trip.
52
-
53
- What the game measured is the reason for the number. Every request costs
54
- about six hundred and thirty milliseconds before any bytes move, and the
55
- lane carries roughly 0.79 MB/s. So a three hundred kilobyte file spends
56
- sixty percent of its time on overhead and a seven megabyte file spends
57
- seven. The line belongs where the overhead stops dominating, which is a few
58
- megabytes and not a few kilobytes.
59
-
60
- Four megabytes was the first attempt and it overshot. Measured on the same
61
- game twice: four hundred kilobyte files went from 1.80 to 3.24 MB/s when
62
- batched, while 1.3 megabyte files went from 4.43 down to 3.88 — because
63
- three concurrent batches move less than six concurrent requests once a file
64
- is large enough that transfer, not overhead, dominates. The crossover is
65
- around a megabyte.
46
+ What counts as small enough to travel with others, defined once in
47
+ staging.ts and imported here. The planner groups sections by the same line
48
+ the sender batches on, which for a while they did not — see the note beside
49
+ PACKABLE_LIMIT for what that cost.
66
50
  */
67
- const BATCH_FILE_LIMIT = 1024 * 1024;
51
+ const BATCH_FILE_LIMIT = staging_js_1.PACKABLE_LIMIT;
68
52
  /** How much one batch may carry, and how many objects it may name. */
69
53
  const BATCH_BYTES = 24 * 1024 * 1024;
70
54
  const BATCH_COUNT = 256;
@@ -266,12 +250,11 @@ class Uploader {
266
250
  if (!ticked.size)
267
251
  throw new Error("Nothing is selected to upload");
268
252
  const prior = request.repositoryId
269
- ? await this.latestVersionFiles(request.repositoryId)
253
+ ? await this.versionFiles(request.repositoryId, request.baseVersionId)
270
254
  : new Map();
271
255
  // Ticked files are sent; everything else the project still has keeps the
272
256
  // copy already stored, and a file that is new but unticked is left out.
273
257
  const sending = everything.filter((file) => ticked.has(file.path));
274
- const onDiskNow = new Set(everything.map((file) => file.path));
275
258
  /*
276
259
  Everything the version already holds is kept, except what this
277
260
  workspace deliberately removed.
@@ -282,28 +265,21 @@ class Uploader {
282
265
  next save. Reading this from the disk scan alone is what silently lost
283
266
  other people's files.
284
267
  */
285
- const knew = request.known ? new Set(Object.keys(request.known)) : null;
286
- const reused = [...prior.entries()]
287
- .filter(([path]) => {
288
- if (ticked.has(path))
289
- return false;
290
- if (onDiskNow.has(path))
291
- return true;
292
- // Missing here. Only a deletion if this workspace ever had it.
293
- return knew ? !knew.has(path) : true;
294
- })
295
- .map(([path, held]) => ({
296
- path,
297
- sourceSize: held.sourceSize,
298
- storedSize: held.storedSize,
299
- mediaType: held.mediaType,
300
- added: 0,
301
- removed: 0,
302
- included: true,
303
- deleted: false,
304
- binary: false,
305
- lines: 0,
306
- }));
268
+ const reused = (0, publication_js_1.retainedBasePaths)(prior.keys(), ticked).map((path) => {
269
+ const held = prior.get(path);
270
+ return {
271
+ path,
272
+ sourceSize: held.sourceSize,
273
+ storedSize: held.storedSize,
274
+ mediaType: held.mediaType,
275
+ added: 0,
276
+ removed: 0,
277
+ included: true,
278
+ deleted: false,
279
+ binary: false,
280
+ lines: 0,
281
+ };
282
+ });
307
283
  /*
308
284
  A ticked path that the rescan cannot see is either a deletion or a file
309
285
  that has gone since the changes list was drawn. Deletions are meant to
@@ -311,7 +287,8 @@ class Uploader {
311
287
  so it is collected and reported rather than quietly skipped.
312
288
  */
313
289
  const onDisk = new Set(everything.map((file) => file.path));
314
- const vanished = [...ticked].filter((chosen) => !onDisk.has(chosen) && !prior.has(chosen));
290
+ const deletions = new Set(request.deletions ?? []);
291
+ const vanished = [...ticked].filter((chosen) => !onDisk.has(chosen) && !deletions.has(chosen));
315
292
  // 1. Measure and hash. This is what makes an unchanged file free.
316
293
  const declarations = [];
317
294
  let totalBytes = 0;
@@ -722,8 +699,11 @@ class Uploader {
722
699
  whole: cutting it up would trade one request for several and save
723
700
  nothing.
724
701
  */
725
- if (declaration.size > CHUNK_THRESHOLD && (await this.chunkingAllowed())) {
726
- const pieces = await this.putChunked(repositoryId, declaration, (offset) => {
702
+ const chunkProfile = (0, chunking_js_1.profileForFileSize)(declaration.size);
703
+ if (chunkProfile &&
704
+ declaration.size >= CHUNK_THRESHOLD &&
705
+ (await this.chunkingAllowed())) {
706
+ const pieces = await this.putChunked(repositoryId, declaration, chunkProfile, (offset) => {
727
707
  inFlight.set(index, offset);
728
708
  report({
729
709
  stage: "upload",
@@ -889,7 +869,12 @@ class Uploader {
889
869
  tool must never have.
890
870
  */
891
871
  const named = new Set(contents.map((item) => item.path));
892
- const dropped = [...new Set([...vanished, ...[...ticked].filter((chosen) => !named.has(chosen))])];
872
+ const dropped = [
873
+ ...new Set([
874
+ ...vanished,
875
+ ...[...ticked].filter((chosen) => !named.has(chosen) && !deletions.has(chosen)),
876
+ ]),
877
+ ];
893
878
  if (dropped.length) {
894
879
  const shown = dropped.slice(0, 5).join(", ");
895
880
  const rest = dropped.length > 5 ? ` and ${dropped.length - 5} more` : "";
@@ -919,6 +904,12 @@ class Uploader {
919
904
  contentType: "application/json",
920
905
  body: JSON.stringify({
921
906
  message: request.message.trim() || "Saved from the CodeRook desktop app",
907
+ ...(request.baseVersionId === undefined
908
+ ? {}
909
+ : {
910
+ ancestryMode: "required",
911
+ baseVersionId: request.baseVersionId,
912
+ }),
922
913
  ...(request.expectedHeadVersionId === undefined
923
914
  ? {}
924
915
  : { expectedHeadVersionId: request.expectedHeadVersionId }),
@@ -968,6 +959,17 @@ class Uploader {
968
959
  })),
969
960
  }),
970
961
  });
962
+ /*
963
+ A clean stale-base save may contain a server-side merge. The candidate
964
+ manifest built above is then not the Version that landed: it lacks the
965
+ other publisher's safe changes. Persist the service's accepted snapshot
966
+ instead. A repeated attempt predates manifest echoing, so read that
967
+ Version once rather than guessing.
968
+ */
969
+ let acceptedManifest = (0, publication_js_1.acceptedManifestDigests)(completed.manifest?.files);
970
+ if (!completed.mergeTrack && !Object.keys(acceptedManifest).length) {
971
+ acceptedManifest = Object.fromEntries([...(await this.versionFiles(repositoryId, completed.version.id))].map(([file, descriptor]) => [file, descriptor.sha256]));
972
+ }
971
973
  report({
972
974
  stage: "done",
973
975
  files: declarations.length,
@@ -984,8 +986,8 @@ class Uploader {
984
986
  sequence: completed.version.sequence,
985
987
  ...(completed.mergeTrack ? { mergeTrack: completed.mergeTrack } : {}),
986
988
  ...(completed.repeated ? { repeated: true } : {}),
987
- sourceBytes,
988
- storedBytes,
989
+ sourceBytes: completed.version.sourceSize ?? sourceBytes,
990
+ storedBytes: completed.version.storedSize ?? storedBytes,
989
991
  sentBytes,
990
992
  sentFiles: uploaded.length - alreadyOnAccount,
991
993
  reusedFiles: reused.length,
@@ -1008,22 +1010,30 @@ class Uploader {
1008
1010
  const before = request.known?.[file.path] ?? prior.get(file.path)?.sha256;
1009
1011
  return before ? [[file.path, before]] : [];
1010
1012
  })),
1011
- manifest: {
1012
- ...Object.fromEntries(reused.map((file) => [file.path, prior.get(file.path).sha256])),
1013
- ...Object.fromEntries(declarations.map((declaration) => [declaration.path, declaration.sha256])),
1014
- },
1013
+ manifest: Object.keys(acceptedManifest).length
1014
+ ? acceptedManifest
1015
+ : {
1016
+ ...Object.fromEntries(reused.map((file) => [file.path, prior.get(file.path).sha256])),
1017
+ ...Object.fromEntries(declarations.map((declaration) => [declaration.path, declaration.sha256])),
1018
+ },
1015
1019
  };
1016
1020
  }
1017
1021
  /** What the newest version holds, so unchanged files can point at it. */
1018
- async latestVersionFiles(repositoryId) {
1022
+ async versionFiles(repositoryId, versionId) {
1019
1023
  const held = new Map();
1020
1024
  try {
1021
- const versions = await this.call(`/v1/repositories/${repositoryId}/versions`, { method: "GET" });
1022
- // The API returns them newest first.
1023
- const latest = (versions.versions ?? [])[0];
1024
- if (!latest?.id)
1025
+ // Explicit null is a genuinely empty base. Undefined belongs only to
1026
+ // legacy callers, which retain their historical newest-Version lookup.
1027
+ if (versionId === null)
1028
+ return held;
1029
+ let wanted = versionId;
1030
+ if (wanted === undefined) {
1031
+ const versions = await this.call(`/v1/repositories/${repositoryId}/versions`, { method: "GET" });
1032
+ wanted = (versions.versions ?? [])[0]?.id;
1033
+ }
1034
+ if (!wanted)
1025
1035
  return held;
1026
- const body = await this.call(`/v1/repositories/${repositoryId}/versions/${latest.id}/files`, {
1036
+ const body = await this.call(`/v1/repositories/${repositoryId}/versions/${wanted}/files`, {
1027
1037
  method: "GET",
1028
1038
  });
1029
1039
  for (const row of body.files ?? []) {
@@ -1254,7 +1264,7 @@ class Uploader {
1254
1264
  * content-addressed object, which is what makes "the service already has
1255
1265
  * this one" a question the existing protocol can already answer.
1256
1266
  */
1257
- async putChunked(repositoryId, declaration, onOffset) {
1267
+ async putChunked(repositoryId, declaration, profile, onOffset) {
1258
1268
  /*
1259
1269
  Read in windows, not all at once.
1260
1270
 
@@ -1310,7 +1320,7 @@ class Uploader {
1310
1320
  const incoming = Buffer.from(block);
1311
1321
  pending = pending.length ? Buffer.concat([pending, incoming]) : incoming;
1312
1322
  let from = 0;
1313
- for (const cut of (0, cbx_js_1.cutPoints)(pending)) {
1323
+ for (const cut of (0, chunking_js_1.cutPoints)(pending, profile)) {
1314
1324
  await dispatch(Buffer.from(pending.subarray(from, cut)), ordinal++);
1315
1325
  from = cut;
1316
1326
  }
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+ /**
3
+ * CodeRook's portable content-defined chunking contract.
4
+ *
5
+ * This file deliberately depends on no Node, Electron or browser APIs. The
6
+ * CLI, Desktop app and website can therefore cut the same bytes at the same
7
+ * positions. Storage manifests record the profile id; changing a profile
8
+ * means adding a new id, never changing the meaning of an existing one.
9
+ */
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;
12
+ exports.profileForFileSize = profileForFileSize;
13
+ exports.cutPoints = cutPoints;
14
+ exports.chunkBoundaries = chunkBoundaries;
15
+ exports.manifestChunking = manifestChunking;
16
+ const KIB = 1024;
17
+ const MIB = 1024 * KIB;
18
+ /*
19
+ * Kept byte-for-byte for compatibility tests and for diagnosing old bundles.
20
+ * The seven-bit pre-target mask is why this profile cut shortly after its
21
+ * minimum instead of averaging eight MiB.
22
+ */
23
+ exports.LEGACY_CHUNK_PROFILE = Object.freeze({
24
+ id: "gear-legacy-v1",
25
+ algorithm: "gear-streaming",
26
+ minSize: 2 * MIB,
27
+ targetSize: 8 * MIB,
28
+ maxSize: 32 * MIB,
29
+ strictMask: 0x0003_5403,
30
+ looseMask: 0x0003_5000,
31
+ });
32
+ /*
33
+ * FastCDC normalisation uses a boundary that is half as likely before the
34
+ * target and twice as likely afterwards. Contiguous low-bit masks contain
35
+ * exactly the documented number of bits; the rolling gear hash mixes the
36
+ * current byte into those bits on every step.
37
+ */
38
+ exports.MEDIUM_CHUNK_PROFILE = Object.freeze({
39
+ id: "fastcdc-v2-medium",
40
+ algorithm: "fastcdc-v2",
41
+ minSize: 256 * KIB,
42
+ targetSize: 1 * MIB,
43
+ maxSize: 4 * MIB,
44
+ strictMask: 0x001f_ffff,
45
+ looseMask: 0x0007_ffff,
46
+ });
47
+ exports.LARGE_CHUNK_PROFILE = Object.freeze({
48
+ id: "fastcdc-v2-large",
49
+ algorithm: "fastcdc-v2",
50
+ minSize: 1 * MIB,
51
+ targetSize: 4 * MIB,
52
+ maxSize: 16 * MIB,
53
+ strictMask: 0x007f_ffff,
54
+ looseMask: 0x001f_ffff,
55
+ });
56
+ exports.HUGE_CHUNK_PROFILE = Object.freeze({
57
+ id: "fastcdc-v2-huge",
58
+ algorithm: "fastcdc-v2",
59
+ minSize: 2 * MIB,
60
+ targetSize: 8 * MIB,
61
+ maxSize: 32 * MIB,
62
+ strictMask: 0x00ff_ffff,
63
+ looseMask: 0x003f_ffff,
64
+ });
65
+ exports.DEFAULT_CHUNK_PROFILE = exports.HUGE_CHUNK_PROFILE;
66
+ /** The profile repository uploads use for a file of `size` bytes. */
67
+ function profileForFileSize(size) {
68
+ if (!Number.isFinite(size) || size < 0) {
69
+ throw new RangeError("File size must be a finite non-negative number");
70
+ }
71
+ if (size < 8 * MIB)
72
+ return null;
73
+ if (size < 128 * MIB)
74
+ return exports.MEDIUM_CHUNK_PROFILE;
75
+ if (size < 2 * 1024 * MIB)
76
+ return exports.LARGE_CHUNK_PROFILE;
77
+ return exports.HUGE_CHUNK_PROFILE;
78
+ }
79
+ const GEAR = (() => {
80
+ const table = new Uint32Array(256);
81
+ let seed = 0x9e3779b9;
82
+ for (let index = 0; index < 256; index += 1) {
83
+ seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
84
+ table[index] = seed;
85
+ }
86
+ return table;
87
+ })();
88
+ /**
89
+ * Cut positions inside `bytes`; the final tail is deliberately left to the
90
+ * streaming caller. Supplying the same bytes and profile on any platform
91
+ * must always return the same positions.
92
+ */
93
+ function cutPoints(bytes, profile = exports.DEFAULT_CHUNK_PROFILE) {
94
+ const cuts = [];
95
+ let from = 0;
96
+ let hash = 0;
97
+ for (let at = 0; at < bytes.length; at += 1) {
98
+ hash = ((hash << 1) + GEAR[bytes[at]]) >>> 0;
99
+ const length = at - from + 1;
100
+ if (length < profile.minSize)
101
+ continue;
102
+ const mask = length < profile.targetSize
103
+ ? profile.strictMask
104
+ : profile.looseMask;
105
+ if (length >= profile.maxSize || (hash & mask) === 0) {
106
+ cuts.push(at + 1);
107
+ from = at + 1;
108
+ hash = 0;
109
+ }
110
+ }
111
+ return cuts;
112
+ }
113
+ /** Full piece boundaries, including the final tail. */
114
+ function chunkBoundaries(bytes, profile = exports.DEFAULT_CHUNK_PROFILE) {
115
+ const bounds = cutPoints(bytes, profile);
116
+ if (bounds[bounds.length - 1] !== bytes.length)
117
+ bounds.push(bytes.length);
118
+ return bounds;
119
+ }
120
+ function manifestChunking(profile) {
121
+ return {
122
+ algorithm: profile.algorithm,
123
+ profile: profile.id,
124
+ min_size: profile.minSize,
125
+ avg_size: profile.targetSize,
126
+ max_size: profile.maxSize,
127
+ };
128
+ }
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.retainedBasePaths = retainedBasePaths;
4
+ exports.acceptedManifestDigests = acceptedManifestDigests;
5
+ /**
6
+ * Paths from the base Version that remain in a candidate snapshot.
7
+ *
8
+ * A Version is a complete immutable view, never a list of changed files.
9
+ * Selected paths are replaced by freshly read declarations, or removed when
10
+ * explicitly marked as deletions. Everything unselected retains the exact
11
+ * object, chunk list, or pack slice from the base.
12
+ */
13
+ function retainedBasePaths(basePaths, selectedPaths) {
14
+ return [...basePaths]
15
+ .filter((path) => !selectedPaths.has(path))
16
+ .sort((left, right) => left.localeCompare(right));
17
+ }
18
+ /** Digest map returned by the accepted server manifest after an auto-merge. */
19
+ function acceptedManifestDigests(files) {
20
+ return Object.fromEntries([...(files ?? [])].flatMap((file) => file.path && file.sha256 ? [[file.path, file.sha256]] : []));
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
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",