@coderook/cli 0.46.1 → 0.46.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.
@@ -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, with cbx or plain git, from Claude Code.",
5
- "version": "0.46.1",
5
+ "version": "0.46.2",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -14,6 +14,7 @@ exports.Uploader = exports.UploadCancelled = void 0;
14
14
  */
15
15
  const node_crypto_1 = require("node:crypto");
16
16
  const node_fs_1 = require("node:fs");
17
+ const node_zlib_1 = require("node:zlib");
17
18
  const promises_1 = require("node:fs/promises");
18
19
  const node_path_1 = __importDefault(require("node:path"));
19
20
  const faults_js_1 = require("./faults.js");
@@ -133,6 +134,11 @@ const versionListings = new Map();
133
134
  const signatures = new Map();
134
135
  /** How many version listings a folder keeps in `.coderook/versions`. */
135
136
  const KEPT_LISTINGS = 3;
137
+ /* The largest new file a one-request delta save carries as its own bytes. */
138
+ const DELTA_SAVE_NEW_FILE_BYTES = 1024 * 1024;
139
+ /** A delta save the service would not take; the save is sent the ordinary way. */
140
+ class DeltaSaveRefused extends Error {
141
+ }
136
142
  class Uploader {
137
143
  credentials;
138
144
  aborted = false;
@@ -147,6 +153,12 @@ class Uploader {
147
153
  deltaProtocol = null;
148
154
  /** Batch delta is separate so V2 single-file support remains compatible. */
149
155
  deltaBatchSupported = null;
156
+ /* Set once the service refuses a one-request delta save; later saves go the ordinary way. */
157
+ deltaSaveRefused = false;
158
+ /* Digests the plan asked about and heard were not held; see storedInBulk. */
159
+ plannedAbsent = new Set();
160
+ /** Batch signatures fetched while pricing a save, used once by the pass that sends it. */
161
+ plannedSignatures = new Map();
150
162
  versionDeltaSupported = null;
151
163
  microchunkSupported = null;
152
164
  /** One shared preflight, so parallel upload lanes cannot race /health. */
@@ -399,6 +411,8 @@ class Uploader {
399
411
  this.planningCreatedRepositoryId = null;
400
412
  this.plannedObjects.clear();
401
413
  this.plannedLayouts.clear();
414
+ this.plannedSignatures.clear();
415
+ this.plannedAbsent.clear();
402
416
  try {
403
417
  await this.runPass(request, report);
404
418
  const quote = this.plannedQuote;
@@ -547,7 +561,7 @@ class Uploader {
547
561
  if (!ticked.size)
548
562
  throw new Error("Nothing is selected to upload");
549
563
  const prior = request.repositoryId
550
- ? await this.versionFiles(request.repositoryId, request.baseVersionId, request.localPath)
564
+ ? await this.versionFiles(request.repositoryId, request.baseVersionId, request.listingRoot ?? request.localPath)
551
565
  : new Map();
552
566
  // Ticked files are sent; everything else the project still has keeps the
553
567
  // copy already stored, and a file that is new but unticked is left out.
@@ -1119,6 +1133,22 @@ class Uploader {
1119
1133
  },
1120
1134
  }
1121
1135
  : null;
1136
+ /*
1137
+ Names this attempt so a retry after a lost connection is answered with
1138
+ the version already made, rather than making a second one. Named by what
1139
+ each file is: a whole file is its object; a chunked one, or one sent as
1140
+ a patch, is the digest of its contents — so retrying the same publish
1141
+ produces the same key either way, which is the point of it.
1142
+ */
1143
+ const idempotencyKey = (0, publish_name_js_1.publishAttemptName)({
1144
+ repositoryId,
1145
+ expectedHeadVersionId: request.expectedHeadVersionId,
1146
+ message: request.message,
1147
+ files: contents.map((item) => ({
1148
+ path: item.path,
1149
+ objectId: item.objectId ?? item.sha256 ?? "",
1150
+ })),
1151
+ });
1122
1152
  const send = (asChanges) => {
1123
1153
  completing("confirming", "Files uploaded · waiting for CodeRook to confirm the save");
1124
1154
  return (0, profile_js_1.timed)("publish the version", () => this.call(`/v1/repositories/${repositoryId}/versions`, {
@@ -1150,20 +1180,7 @@ class Uploader {
1150
1180
  Names this attempt so a retry after a lost connection is answered
1151
1181
  with the version already made, rather than making a second one.
1152
1182
  */
1153
- idempotencyKey: (0, publish_name_js_1.publishAttemptName)({
1154
- repositoryId,
1155
- expectedHeadVersionId: request.expectedHeadVersionId,
1156
- message: request.message,
1157
- /*
1158
- Named by what each file is. A whole file is its object; a chunked
1159
- one is the digest of its contents — so retrying the same publish
1160
- produces the same key either way, which is the point of it.
1161
- */
1162
- files: contents.map((item) => ({
1163
- path: item.path,
1164
- objectId: item.objectId ?? item.sha256 ?? "",
1165
- })),
1166
- }),
1183
+ idempotencyKey,
1167
1184
  sourceSize: sourceBytes,
1168
1185
  storedSize: storedBytes,
1169
1186
  /*
@@ -1175,17 +1192,83 @@ class Uploader {
1175
1192
  }));
1176
1193
  };
1177
1194
  /*
1178
- A service that cannot build on the version named (it was taken down,
1179
- say) answers files_from_*; the whole list is then sent instead, which
1180
- every service accepts.
1195
+ The whole save as one request (delta-save-v1): a small header saying
1196
+ what POST /versions would be told, less the file list, then the exact
1197
+ patches. Packed with gzip when that is smaller, which it is once a few
1198
+ paths repeat their folders.
1181
1199
  */
1182
- const completed = await send(true).catch((error) => {
1183
- const code = error.code;
1184
- if (changesOnly && typeof code === "string" && code.startsWith("files_from_")) {
1185
- return send(false);
1200
+ const sendDeltaSave = async (plan) => {
1201
+ const sentHere = new Set(plan.entries.map((entry) => entry.declaration.path));
1202
+ const removed = [...prior.keys()].filter((held) => !named.has(held));
1203
+ const executable = contents
1204
+ .filter((item) => item.executable === true && sentHere.has(item.path))
1205
+ .map((item) => item.path);
1206
+ /* Anything the service assumes by default is left out: the expected head is the base unless said otherwise. */
1207
+ const header = new TextEncoder().encode(JSON.stringify({
1208
+ message: request.message.trim() || "Saved from the CodeRook desktop app",
1209
+ ...(request.expectedHeadVersionId === undefined || request.expectedHeadVersionId === request.baseVersionId
1210
+ ? {}
1211
+ : { expectedHeadVersionId: request.expectedHeadVersionId }),
1212
+ ...(request.track && request.track !== "main" ? { track: request.track } : {}),
1213
+ idempotencyKey,
1214
+ ...(plan.entries.length ? {} : { baseVersionId: request.baseVersionId }),
1215
+ ...(linesAdded === undefined ? {} : { linesAdded }),
1216
+ ...(linesRemoved === undefined ? {} : { linesRemoved }),
1217
+ ...(request.allowIgnored ? { allowIgnored: true } : {}),
1218
+ ...(this.trackedByGit.length ? { trackedByGit: this.trackedByGit } : {}),
1219
+ ...(removed.length ? { removed } : {}),
1220
+ ...(executable.length ? { executable } : {}),
1221
+ }));
1222
+ const rest = new Uint8Array(4 + header.byteLength + plan.envelope.byteLength);
1223
+ new DataView(rest.buffer).setUint32(0, header.byteLength, false);
1224
+ rest.set(header, 4);
1225
+ rest.set(plan.envelope, 4 + header.byteLength);
1226
+ const packed = new Uint8Array((0, node_zlib_1.gzipSync)(rest, { level: 9 }));
1227
+ const packing = packed.byteLength < rest.byteLength;
1228
+ const body = new Uint8Array(4 + (packing ? packed.byteLength : rest.byteLength));
1229
+ body.set(new TextEncoder().encode(packing ? "CDVZ" : "CDV1"), 0);
1230
+ body.set(packing ? packed : rest, 4);
1231
+ completing("confirming", "Sending the changes · waiting for CodeRook to confirm the save");
1232
+ try {
1233
+ const answer = await (0, profile_js_1.timed)("send the save as one request", () => this.call(`/v1/repositories/${repositoryId}/versions/delta`, {
1234
+ method: "POST",
1235
+ contentType: "application/vnd.coderook.delta-save; version=1",
1236
+ body,
1237
+ }));
1238
+ progress.finished(plan.entries.reduce((sum, entry) => sum + entry.declaration.size, 0), body.byteLength);
1239
+ return answer;
1186
1240
  }
1187
- throw error;
1188
- });
1241
+ catch (error) {
1242
+ const code = error.code;
1243
+ const status = error.status;
1244
+ if ((typeof code === "string" && (code.startsWith("delta_") || code.startsWith("files_from_"))) ||
1245
+ status === 404 ||
1246
+ status === 405 ||
1247
+ status === 415) {
1248
+ throw new DeltaSaveRefused(error instanceof Error ? error.message : String(error));
1249
+ }
1250
+ throw error;
1251
+ }
1252
+ };
1253
+ const completed = at.deltaSave
1254
+ ? await sendDeltaSave(at.deltaSave)
1255
+ : await send(true).catch((error) => {
1256
+ const code = error.code;
1257
+ if (changesOnly && typeof code === "string" && code.startsWith("files_from_")) {
1258
+ return send(false);
1259
+ }
1260
+ throw error;
1261
+ });
1262
+ if (at.deltaSave) {
1263
+ const placed = new Map((completed.objects ?? []).map((one) => [one.path, one]));
1264
+ for (const entry of [...wire, ...uploaded]) {
1265
+ const one = placed.get(entry.path);
1266
+ if (!one)
1267
+ continue;
1268
+ entry.objectId = one.objectId;
1269
+ entry.storedSize = one.storedSize;
1270
+ }
1271
+ }
1189
1272
  /*
1190
1273
  A clean stale-base save may contain a server-side merge. The candidate
1191
1274
  manifest built above is then not the Version that landed: it lacks the
@@ -1210,7 +1293,7 @@ class Uploader {
1210
1293
  (await this.versionDeltaAllowed());
1211
1294
  if (!completed.mergeTrack && !landedAsSent && !Object.keys(acceptedManifest).length) {
1212
1295
  completing("local", "CodeRook confirmed the save · reading the accepted file manifest");
1213
- acceptedManifest = Object.fromEntries([...(await this.versionFiles(repositoryId, completed.version.id, request.localPath))].map(([file, descriptor]) => [file, descriptor.sha256]));
1296
+ acceptedManifest = Object.fromEntries([...(await this.versionFiles(repositoryId, completed.version.id, request.listingRoot ?? request.localPath))].map(([file, descriptor]) => [file, descriptor.sha256]));
1214
1297
  }
1215
1298
  const manifest = Object.keys(acceptedManifest).length
1216
1299
  ? acceptedManifest
@@ -1275,13 +1358,13 @@ class Uploader {
1275
1358
  folder now, from the list just sent — every entry with its digest — and
1276
1359
  that save does not have to download it.
1277
1360
  */
1278
- if (landedAsSent && request.localPath) {
1361
+ if (landedAsSent && (request.listingRoot ?? request.localPath)) {
1279
1362
  const rows = wire.map((entry) => ({ ...entry, sha256: manifest[entry.path] }));
1280
1363
  if (rows.every((row) => typeof row.sha256 === "string" && row.sha256.length === 64)) {
1281
1364
  const key = `${repositoryId}/${completed.version.id}`;
1282
1365
  versionListings.set(key, Promise.resolve(rows));
1283
1366
  completing("local", "CodeRook confirmed the save · caching the file listing for next time");
1284
- await writeListing(request.localPath, repositoryId, completed.version.id, rows);
1367
+ await writeListing(request.listingRoot ?? request.localPath, repositoryId, completed.version.id, rows);
1285
1368
  }
1286
1369
  }
1287
1370
  report({
@@ -1497,7 +1580,7 @@ class Uploader {
1497
1580
  if (deltaDeclaration &&
1498
1581
  smallOnes.some((one) => one.path === deltaDeclaration.path)) {
1499
1582
  const held = prior.get(deltaDeclaration.path);
1500
- const placed = await (0, profile_js_1.timed)("send changed-file delta", () => this.putDelta(repositoryId, request.baseVersionId, held, deltaDeclaration));
1583
+ const placed = await (0, profile_js_1.timed)("send changed-file delta", () => this.putDelta(repositoryId, request.baseVersionId, held, deltaDeclaration, request.localBase));
1501
1584
  if (placed) {
1502
1585
  batched.set(deltaDeclaration.sha256, {
1503
1586
  objectId: placed.objectId,
@@ -1622,7 +1705,7 @@ class Uploader {
1622
1705
  : null;
1623
1706
  const packedBytes = built ? this.frameSolidPack(built).byteLength : 0;
1624
1707
  const delta = built
1625
- ? await (0, profile_js_1.timed)("price changed-file batch", () => this.putDeltaBatch(repositoryId, request.baseVersionId, taking, prior, packedBytes))
1708
+ ? await (0, profile_js_1.timed)("price changed-file batch", () => this.putDeltaBatch(repositoryId, request.baseVersionId, taking, prior, packedBytes, request.localBase))
1626
1709
  : null;
1627
1710
  if (delta) {
1628
1711
  (0, profile_js_1.counted)("files sent as delta batch", taking.length);
@@ -1790,6 +1873,7 @@ class Uploader {
1790
1873
  if (chunkProfile &&
1791
1874
  declaration.size >= CHUNK_THRESHOLD &&
1792
1875
  (await this.chunkingAllowed())) {
1876
+ const priorFile = prior.get(declaration.path);
1793
1877
  const pieces = await this.putChunked(repositoryId, declaration, chunkProfile, (offset) => {
1794
1878
  progress.lane(index, offset);
1795
1879
  report({
@@ -1803,7 +1887,11 @@ class Uploader {
1803
1887
  Math.round((progress.bytes() / Math.max(totalBytes, 1)) * 72),
1804
1888
  bytesPerSecond: progress.rate(),
1805
1889
  });
1806
- });
1890
+ },
1891
+ /* The previous version's copy of this file, kept in pieces: what a changed piece is patched against. */
1892
+ request.baseVersionId && priorFile?.chunks?.length && priorFile.sha256
1893
+ ? { baseVersionId: request.baseVersionId, baseSha256: priorFile.sha256, baseSize: priorFile.sourceSize }
1894
+ : undefined);
1807
1895
  progress.laneDone(index);
1808
1896
  /*
1809
1897
  A chunk is not a file. Counting every reused piece here made a
@@ -2124,6 +2212,97 @@ class Uploader {
2124
2212
  and eighteen in the other, which is not two pipelines. Judging by the
2125
2213
  share of bytes that will be batched splits the same game nine and twelve.
2126
2214
  */
2215
+ /*
2216
+ A save of edited files whose bases this client holds goes up as one
2217
+ request (delta-save-v1): the exact patches and the version together, as
2218
+ a Git push carries its objects and its commit in one pack. Nothing is
2219
+ asked about first and nothing is priced object by object; the plan still
2220
+ asks the service for the account's storage and allowance, with an empty
2221
+ object list, so what a person is shown before saving stays true.
2222
+ */
2223
+ const oneRequest = this.deltaSaveRefused
2224
+ ? null
2225
+ : await this.deltaSavePlan(request, repositoryId, declarations, prior);
2226
+ if (oneRequest && this.planning) {
2227
+ report({
2228
+ stage: "publish",
2229
+ files: declarations.length,
2230
+ totalFiles: declarations.length,
2231
+ bytes: 0,
2232
+ totalBytes,
2233
+ path: "Checking storage and monthly allowance",
2234
+ percent: 96,
2235
+ bytesPerSecond: 0,
2236
+ });
2237
+ this.plannedQuote = await this.call(`/v1/repositories/${repositoryId}/uploads/preflight`, {
2238
+ method: "POST",
2239
+ contentType: "application/json",
2240
+ body: JSON.stringify({
2241
+ sourceBytes: totalBytes,
2242
+ excludedBytes: 0,
2243
+ objects: [],
2244
+ track: request.track ?? "main",
2245
+ }),
2246
+ });
2247
+ return {
2248
+ repositoryId,
2249
+ versionId: "",
2250
+ sequence: 0,
2251
+ sourceBytes: totalBytes,
2252
+ storedBytes: this.plannedQuote.compactedBytes,
2253
+ sentBytes: oneRequest.patchBytes,
2254
+ sentFiles: oneRequest.entries.length,
2255
+ reusedFiles: reused.length,
2256
+ alreadyStoredFiles: 0,
2257
+ manifest: {},
2258
+ local: {},
2259
+ telemetry: this.telemetry.snapshot(),
2260
+ };
2261
+ }
2262
+ if (oneRequest) {
2263
+ this.sendingBegan = true;
2264
+ for (const entry of oneRequest.entries) {
2265
+ uploaded.push({
2266
+ path: entry.declaration.path,
2267
+ sha256: entry.declaration.sha256,
2268
+ sourceSize: entry.declaration.size,
2269
+ storedSize: 0,
2270
+ mediaType: entry.declaration.mediaType,
2271
+ });
2272
+ }
2273
+ try {
2274
+ return await this.publishVersion({
2275
+ request,
2276
+ repositoryId,
2277
+ declarations,
2278
+ uploaded,
2279
+ reused,
2280
+ prior,
2281
+ everything,
2282
+ ticked,
2283
+ deletions,
2284
+ readFailures,
2285
+ vanished,
2286
+ totalBytes,
2287
+ progress,
2288
+ report,
2289
+ deltaSave: oneRequest,
2290
+ });
2291
+ }
2292
+ catch (error) {
2293
+ if (!(error instanceof DeltaSaveRefused))
2294
+ throw error;
2295
+ /*
2296
+ Refused as a delta save (an old base, a patch that did not rebuild):
2297
+ sent the ordinary way instead. The plan's quote priced no objects,
2298
+ so the objects go up unquoted, as they did before quotes existed.
2299
+ */
2300
+ this.deltaSaveRefused = true;
2301
+ this.activeQuoteId = null;
2302
+ uploaded.length = 0;
2303
+ (0, profile_js_1.counted)("delta saves sent the ordinary way", 1);
2304
+ }
2305
+ }
2127
2306
  await this.sendObjects({
2128
2307
  request,
2129
2308
  repositoryId,
@@ -2407,7 +2586,7 @@ class Uploader {
2407
2586
  * member has an immutable base and the complete two-way wire cost wins by
2408
2587
  * the same twenty-percent margin used by the single-file transport.
2409
2588
  */
2410
- async putDeltaBatch(repositoryId, baseVersionId, items, prior, solidPackBytes) {
2589
+ async putDeltaBatch(repositoryId, baseVersionId, items, prior, solidPackBytes, localBase) {
2411
2590
  if (!baseVersionId ||
2412
2591
  solidPackBytes <= 0 ||
2413
2592
  items.length < 2 ||
@@ -2415,7 +2594,6 @@ class Uploader {
2415
2594
  !(await this.deltaBatchAllowed()))
2416
2595
  return null;
2417
2596
  let sourceBytes = 0;
2418
- let signatureEstimate = 6 + items.length * 4;
2419
2597
  const bases = [];
2420
2598
  for (const item of items) {
2421
2599
  const base = prior.get(item.declaration.path);
@@ -2430,46 +2608,46 @@ class Uploader {
2430
2608
  sourceBytes += item.declaration.size;
2431
2609
  if (sourceBytes > delta_js_1.DELTA_BATCH_MAX_SOURCE_BYTES)
2432
2610
  return null;
2433
- signatureEstimate += (0, delta_js_1.estimateDeltaSignatureBytes)(base.sourceSize, 2);
2434
2611
  bases.push(base);
2435
2612
  }
2613
+ /* The bases held here need no signature; only the rest are asked for. */
2614
+ const held = await Promise.all(items.map((item, index) => this.localBaseBytes(localBase, item.declaration.path, bases[index])));
2615
+ const asking = items.flatMap((_, index) => (held[index] ? [] : [index]));
2616
+ let signatureEstimate = asking.length ? 6 + asking.length * 4 : 0;
2617
+ for (const index of asking) {
2618
+ signatureEstimate += (0, delta_js_1.estimateDeltaSignatureBytes)(bases[index].sourceSize, 2);
2619
+ }
2436
2620
  if (signatureEstimate >= solidPackBytes * 0.8)
2437
2621
  return null;
2438
2622
  try {
2439
- const token = await this.credentials.token();
2440
- if (!token)
2441
- return null;
2442
- const response = await fetch(`${this.credentials.origin()}/v1/repositories/${repositoryId}/objects/delta/signatures`, {
2443
- method: "POST",
2444
- headers: {
2445
- accept: "application/vnd.coderook.delta-signature-batch; version=2",
2446
- authorization: `Bearer ${token}`,
2447
- "content-type": "application/json",
2448
- "user-agent": "CodeRook/0.1",
2449
- ...(0, identify_js_1.clientHeaders)(),
2450
- },
2451
- body: JSON.stringify({
2452
- baseVersionId,
2453
- files: items.map((item, index) => ({
2454
- path: item.declaration.path,
2455
- baseSha256: bases[index].sha256,
2456
- })),
2457
- }),
2458
- signal: this.controller.signal,
2459
- });
2460
- if (!response.ok)
2623
+ /*
2624
+ Signatures describe the base version's files, which never change, so
2625
+ the ones fetched while pricing the save are the ones sending it needs.
2626
+ Fetching them again in the sending pass doubled what a batch of edits
2627
+ cost on the wire: 16 one-byte edits measured 12.1 KiB, against 8.8 KiB
2628
+ with one fetch.
2629
+ */
2630
+ const signatureBytes = asking.length
2631
+ ? await this.signatureBatch(repositoryId, baseVersionId, asking.map((index) => ({
2632
+ path: items[index].declaration.path,
2633
+ baseSha256: bases[index].sha256,
2634
+ })))
2635
+ : new Uint8Array(0);
2636
+ if (!signatureBytes)
2461
2637
  return null;
2462
- const signatureBytes = new Uint8Array(await response.arrayBuffer());
2463
- const signatures = (0, delta_js_1.decodeDeltaSignatureBatch)(signatureBytes);
2464
- if (signatures.length !== items.length)
2638
+ const signatures = asking.length ? (0, delta_js_1.decodeDeltaSignatureBatch)(signatureBytes) : [];
2639
+ if (signatures.length !== asking.length)
2465
2640
  return null;
2641
+ const signatureFor = new Map(asking.map((index, at) => [index, signatures[at]]));
2466
2642
  const framed = (0, delta_js_1.encodeDeltaBatchEnvelope)({
2467
2643
  baseVersionId,
2468
2644
  entries: items.map((item, index) => ({
2469
2645
  path: item.declaration.path,
2470
2646
  sha256: item.declaration.sha256,
2471
2647
  mediaType: item.declaration.mediaType,
2472
- patch: (0, delta_js_1.createDeltaPatch)(item.body, signatures[index]),
2648
+ patch: held[index]
2649
+ ? (0, delta_js_1.createExactDeltaPatch)(held[index], item.body)
2650
+ : (0, delta_js_1.createDeltaPatch)(item.body, signatureFor.get(index)),
2473
2651
  })),
2474
2652
  });
2475
2653
  const wireBytes = signatureBytes.byteLength + framed.byteLength;
@@ -2520,10 +2698,13 @@ class Uploader {
2520
2698
  * is never a repository object: the service reconstructs and verifies the
2521
2699
  * complete target before returning the ordinary object id used below.
2522
2700
  */
2523
- async putDelta(repositoryId, baseVersionId, prior, declaration) {
2701
+ async putDelta(repositoryId, baseVersionId, prior, declaration, localBase) {
2524
2702
  const deltaVersion = await this.deltaVersion();
2525
2703
  if (deltaVersion === 0)
2526
2704
  return null;
2705
+ const held = deltaVersion === 2
2706
+ ? await this.localBaseBytes(localBase, declaration.path, prior)
2707
+ : null;
2527
2708
  let target;
2528
2709
  try {
2529
2710
  target = new Uint8Array(await (0, promises_1.readFile)(declaration.full));
@@ -2536,14 +2717,19 @@ class Uploader {
2536
2717
  return null;
2537
2718
  const ordinary = await (0, compress_js_1.encodeForUpload)(target, declaration.path, await this.gzipAllowed());
2538
2719
  const signatureEstimate = (0, delta_js_1.estimateDeltaSignatureBytes)(prior.sourceSize, deltaVersion);
2539
- if (signatureEstimate >= ordinary.body.byteLength * 0.8)
2720
+ if (!held && signatureEstimate >= ordinary.body.byteLength * 0.8)
2540
2721
  return null;
2541
2722
  try {
2542
- const signatureBytes = await this.deltaSignature(repositoryId, baseVersionId, declaration.path, prior.sha256, deltaVersion);
2543
- const signature = (0, delta_js_1.parseDeltaSignature)(signatureBytes);
2544
- const patch = (0, delta_js_1.createDeltaPatch)(target, signature);
2723
+ /* A base held here is patched against directly, with nothing fetched. */
2724
+ const signatureBytes = held
2725
+ ? new Uint8Array(0)
2726
+ : await this.deltaSignature(repositoryId, baseVersionId, declaration.path, prior.sha256, deltaVersion);
2727
+ const signature = held ? null : (0, delta_js_1.parseDeltaSignature)(signatureBytes);
2728
+ const patch = held
2729
+ ? (0, delta_js_1.createExactDeltaPatch)(held, target)
2730
+ : (0, delta_js_1.createDeltaPatch)(target, signature);
2545
2731
  let framed;
2546
- if (signature.version === 2) {
2732
+ if (held || signature.version === 2) {
2547
2733
  framed = (0, delta_js_1.encodeDeltaEnvelope)({
2548
2734
  baseVersionId,
2549
2735
  path: declaration.path,
@@ -2595,6 +2781,168 @@ class Uploader {
2595
2781
  return null;
2596
2782
  }
2597
2783
  }
2784
+ /**
2785
+ * The one-request delta save for this selection, or null when it does not
2786
+ * apply: every file being sent must be an edit of a file the base version
2787
+ * holds at the same path, small enough for the delta transport, with its
2788
+ * base held by the caller. A key published on purpose is stored as its
2789
+ * covered copy, not as sent, so those saves keep the ordinary path.
2790
+ */
2791
+ async deltaSavePlan(request, repositoryId, declarations, prior) {
2792
+ if (request.allowSecrets ||
2793
+ typeof request.baseVersionId !== "string" ||
2794
+ !prior.size ||
2795
+ declarations.length > delta_js_1.DELTA_BATCH_MAX_FILES ||
2796
+ !(await this.serviceFeatures()).includes("delta-save-v1"))
2797
+ return null;
2798
+ let sourceBytes = 0;
2799
+ for (const declaration of declarations) {
2800
+ const base = prior.get(declaration.path);
2801
+ if ((base && !base.sha256) ||
2802
+ declaration.size > delta_js_1.DELTA_MAX_FILE_BYTES ||
2803
+ (base?.sourceSize ?? 0) > delta_js_1.DELTA_MAX_FILE_BYTES)
2804
+ return null;
2805
+ sourceBytes += declaration.size;
2806
+ if (sourceBytes > delta_js_1.DELTA_BATCH_MAX_SOURCE_BYTES)
2807
+ return null;
2808
+ }
2809
+ /*
2810
+ Three ways a file goes in, cheapest first. An edit whose base is held
2811
+ here is patched against it exactly. An edit whose base is not is patched
2812
+ against the service's signatures of it, fetched in one request, when
2813
+ those are worth fetching. Anything else small enough goes as its own
2814
+ bytes, a literal patch any base rebuilds: a new file, or a rewrite that
2815
+ shares nothing. Past a megabyte that is not taken, and the save goes the
2816
+ ordinary way, where chunks and packs can find what is already held.
2817
+ */
2818
+ const targets = [];
2819
+ const held = [];
2820
+ const signed = [];
2821
+ for (const [index, declaration] of declarations.entries()) {
2822
+ let target;
2823
+ try {
2824
+ target = new Uint8Array(await (0, promises_1.readFile)(declaration.full));
2825
+ }
2826
+ catch {
2827
+ return null;
2828
+ }
2829
+ if ((0, node_crypto_1.createHash)("sha256").update(target).digest("hex") !== declaration.sha256)
2830
+ return null;
2831
+ targets.push(target);
2832
+ const was = prior.get(declaration.path);
2833
+ const base = was ? await this.localBaseBytes(request.localBase, declaration.path, was) : null;
2834
+ held.push(base);
2835
+ if (base)
2836
+ continue;
2837
+ if (was && (0, delta_js_1.estimateDeltaSignatureBytes)(was.sourceSize, 2) < declaration.size * 0.8) {
2838
+ signed.push(index);
2839
+ }
2840
+ else if (declaration.size > DELTA_SAVE_NEW_FILE_BYTES) {
2841
+ return null;
2842
+ }
2843
+ }
2844
+ let signatureFor = new Map();
2845
+ let signatureBytes = 0;
2846
+ if (signed.length) {
2847
+ const fetched = await this.signatureBatch(repositoryId, request.baseVersionId, signed.map((index) => ({
2848
+ path: declarations[index].path,
2849
+ baseSha256: prior.get(declarations[index].path).sha256,
2850
+ })));
2851
+ if (!fetched)
2852
+ return null;
2853
+ try {
2854
+ const signatures = (0, delta_js_1.decodeDeltaSignatureBatch)(fetched);
2855
+ if (signatures.length !== signed.length)
2856
+ return null;
2857
+ signatureFor = new Map(signed.map((index, at) => [index, signatures[at]]));
2858
+ }
2859
+ catch {
2860
+ return null;
2861
+ }
2862
+ signatureBytes = fetched.byteLength;
2863
+ }
2864
+ const entries = declarations.map((declaration, index) => {
2865
+ const target = targets[index];
2866
+ const base = held[index];
2867
+ const signature = signatureFor.get(index);
2868
+ return {
2869
+ declaration,
2870
+ patch: base
2871
+ ? (0, delta_js_1.createExactDeltaPatch)(base, target)
2872
+ : signature
2873
+ ? (0, delta_js_1.createDeltaPatch)(target, signature)
2874
+ : (0, delta_js_1.createExactDeltaPatch)(new Uint8Array(0), target),
2875
+ };
2876
+ });
2877
+ (0, profile_js_1.counted)("signature bytes for the delta save", signatureBytes);
2878
+ /* A save that only deletes carries no envelope; its header names the base. */
2879
+ const envelope = !entries.length ? new Uint8Array(0) : (0, delta_js_1.encodeDeltaBatchEnvelope)({
2880
+ baseVersionId: request.baseVersionId,
2881
+ entries: entries.map((entry) => ({
2882
+ path: entry.declaration.path,
2883
+ sha256: entry.declaration.sha256,
2884
+ mediaType: entry.declaration.mediaType,
2885
+ patch: entry.patch,
2886
+ })),
2887
+ });
2888
+ return {
2889
+ entries,
2890
+ envelope,
2891
+ patchBytes: entries.reduce((sum, entry) => sum + entry.patch.byteLength, 0),
2892
+ };
2893
+ }
2894
+ /**
2895
+ * Signatures of several base files in one request, or null when refused.
2896
+ *
2897
+ * Signatures describe the base version's files, which never change, so
2898
+ * the ones fetched while pricing the save are the ones sending it needs.
2899
+ * Fetching them again in the sending pass doubled what a batch of edits
2900
+ * cost on the wire: 16 one-byte edits measured 12.1 KiB, against 8.8 KiB
2901
+ * with one fetch.
2902
+ */
2903
+ async signatureBatch(repositoryId, baseVersionId, files) {
2904
+ const key = JSON.stringify([repositoryId, baseVersionId, files.map((file) => [file.path, file.baseSha256])]);
2905
+ const planned = this.planning ? undefined : this.plannedSignatures.get(key);
2906
+ if (planned) {
2907
+ this.plannedSignatures.delete(key);
2908
+ return planned;
2909
+ }
2910
+ const token = await this.credentials.token();
2911
+ if (!token)
2912
+ return null;
2913
+ const response = await fetch(`${this.credentials.origin()}/v1/repositories/${repositoryId}/objects/delta/signatures`, {
2914
+ method: "POST",
2915
+ headers: {
2916
+ accept: "application/vnd.coderook.delta-signature-batch; version=2",
2917
+ authorization: `Bearer ${token}`,
2918
+ "content-type": "application/json",
2919
+ "user-agent": "CodeRook/0.1",
2920
+ ...(0, identify_js_1.clientHeaders)(),
2921
+ },
2922
+ body: JSON.stringify({ baseVersionId, files }),
2923
+ signal: this.controller.signal,
2924
+ });
2925
+ if (!response.ok)
2926
+ return null;
2927
+ const bytes = new Uint8Array(await response.arrayBuffer());
2928
+ if (this.planning)
2929
+ this.plannedSignatures.set(key, bytes);
2930
+ return bytes;
2931
+ }
2932
+ /** A base's bytes from the caller, only when they are exactly the base. */
2933
+ async localBaseBytes(localBase, filePath, base) {
2934
+ if (!localBase || !base.sha256 || base.sourceSize > delta_js_1.DELTA_MAX_FILE_BYTES)
2935
+ return null;
2936
+ try {
2937
+ const bytes = await localBase(filePath, base.sha256);
2938
+ if (!bytes || bytes.byteLength > delta_js_1.DELTA_MAX_FILE_BYTES)
2939
+ return null;
2940
+ return (0, node_crypto_1.createHash)("sha256").update(bytes).digest("hex") === base.sha256 ? bytes : null;
2941
+ }
2942
+ catch {
2943
+ return null;
2944
+ }
2945
+ }
2598
2946
  deltaSignature(repositoryId, baseVersionId, filePath, baseSha256, version) {
2599
2947
  const key = [repositoryId, baseVersionId, filePath, baseSha256, version].join("\u0000");
2600
2948
  const known = signatures.get(key);
@@ -2695,7 +3043,18 @@ class Uploader {
2695
3043
  */
2696
3044
  async storedInBulk(repositoryId, digests) {
2697
3045
  const held = new Map();
2698
- if (!digests.length)
3046
+ /*
3047
+ The plan asked already; what it heard was not held is not asked twice.
3048
+ Asking again cost a whole second request on every save, 34 KB of the
3049
+ first save of 512 files. Only "not held" is reused: sending something
3050
+ that arrived since costs bytes the service then recognises, while an
3051
+ object reported held could have been collected in between, so those
3052
+ are asked again.
3053
+ */
3054
+ const asking = this.planning
3055
+ ? digests
3056
+ : digests.filter((digest) => !this.plannedAbsent.has(digest));
3057
+ if (!asking.length)
2699
3058
  return held;
2700
3059
  try {
2701
3060
  /*
@@ -2703,9 +3062,9 @@ class Uploader {
2703
3062
  thousand digests is roughly a third of a megabyte of JSON, which is
2704
3063
  nothing beside the transfer it is replacing.
2705
3064
  */
2706
- for (let at = 0; at < digests.length; at += 5000) {
3065
+ for (let at = 0; at < asking.length; at += 5000) {
2707
3066
  this.check();
2708
- const batch = [...new Set(digests.slice(at, at + 5000))];
3067
+ const batch = [...new Set(asking.slice(at, at + 5000))];
2709
3068
  const answer = await this.call(`/v1/repositories/${repositoryId}/stored`, {
2710
3069
  method: "POST",
2711
3070
  contentType: "application/json",
@@ -2714,6 +3073,11 @@ class Uploader {
2714
3073
  for (const [digest, one] of Object.entries(answer.stored ?? {})) {
2715
3074
  held.set(digest, { objectId: one.objectId, size: one.storedSize });
2716
3075
  }
3076
+ if (this.planning) {
3077
+ for (const digest of batch)
3078
+ if (!held.has(digest))
3079
+ this.plannedAbsent.add(digest);
3080
+ }
2717
3081
  }
2718
3082
  }
2719
3083
  catch {
@@ -2734,7 +3098,7 @@ class Uploader {
2734
3098
  * content-addressed object, which is what makes "the service already has
2735
3099
  * this one" a question the existing protocol can already answer.
2736
3100
  */
2737
- async putChunked(repositoryId, declaration, profile, onOffset) {
3101
+ async putChunked(repositoryId, declaration, profile, onOffset, deltaBase) {
2738
3102
  /*
2739
3103
  Three steps, and each file is read for as long as it has to be.
2740
3104
 
@@ -2794,6 +3158,9 @@ class Uploader {
2794
3158
  at most a maximum chunk each.
2795
3159
  */
2796
3160
  const CHUNK_LANES = 4;
3161
+ const signatures = deltaBase
3162
+ ? await this.chunkDeltaSignatures(repositoryId, declaration.path, deltaBase, layout, held)
3163
+ : new Map();
2797
3164
  /*
2798
3165
  One verdict for this file, shared by its lanes, and consulted only where
2799
3166
  the plan has not already decided. The send takes each chunk's encoding
@@ -2843,7 +3210,16 @@ class Uploader {
2843
3210
  throw new Error(`${declaration.path} changed while it was being saved. ` +
2844
3211
  "Save again to send what it holds now.");
2845
3212
  }
2846
- await this.sendChunk(repositoryId, declaration, bytes, piece.digest, chunks, at, note, gzip);
3213
+ const viaDelta = deltaBase
3214
+ ? await this.putChunkDelta(repositoryId, declaration, deltaBase, bytes, piece.digest, signatures.get(at))
3215
+ : null;
3216
+ if (viaDelta) {
3217
+ note("sent", viaDelta.sentBytes);
3218
+ chunks[at] = { objectId: viaDelta.objectId, sourceSize: piece.length, storedSize: viaDelta.storedSize };
3219
+ }
3220
+ else {
3221
+ await this.sendChunk(repositoryId, declaration, bytes, piece.digest, chunks, at, note, gzip);
3222
+ }
2847
3223
  }
2848
3224
  done += piece.length;
2849
3225
  onOffset(Math.floor(already + done * share));
@@ -2923,6 +3299,119 @@ class Uploader {
2923
3299
  return layout;
2924
3300
  }
2925
3301
  /** Send one chunk, reusing it if the account already holds those bytes. */
3302
+ /*
3303
+ Chunk deltas (chunk-delta-v1): a changed piece of a large file, sent as a
3304
+ patch against the old pieces covering the same place in the previous
3305
+ version, rather than whole. A 64 KiB edit inside a 768 MiB file changes
3306
+ one piece of about a megabyte, which used to travel complete. The service
3307
+ rebuilds the piece, checks it against its digest and stores it as an
3308
+ ordinary chunk, so nothing stored changes: only what crosses the wire.
3309
+ */
3310
+ chunkDeltaSupported = null;
3311
+ async chunkDeltaAllowed() {
3312
+ if (this.chunkDeltaSupported !== null)
3313
+ return this.chunkDeltaSupported;
3314
+ this.chunkDeltaSupported = (await this.serviceFeatures()).includes("chunk-delta-v1");
3315
+ return this.chunkDeltaSupported;
3316
+ }
3317
+ /**
3318
+ * Receiver signatures for the old bytes behind each changed piece, by the piece's position.
3319
+ * The range is the piece's own place in the file widened by 64 KiB each way, in the base
3320
+ * file's coordinates, so a piece moved a little by an insertion still finds its old bytes.
3321
+ * Empty when the service does not offer chunk deltas or declines; the pieces then go whole.
3322
+ */
3323
+ async chunkDeltaSignatures(repositoryId, path, base, layout, held) {
3324
+ const found = new Map();
3325
+ if (this.planning || !(await this.chunkDeltaAllowed()))
3326
+ return found;
3327
+ const SLACK = 64 * 1024;
3328
+ const wanted = [];
3329
+ layout.forEach((piece, at) => {
3330
+ if (held.has(piece.digest))
3331
+ return;
3332
+ const start = Math.max(0, piece.offset - SLACK);
3333
+ const end = Math.min(base.baseSize, piece.offset + piece.length + SLACK);
3334
+ if (end > start)
3335
+ wanted.push({ at, start, end });
3336
+ });
3337
+ for (let from = 0; from < wanted.length; from += 64) {
3338
+ const group = wanted.slice(from, from + 64);
3339
+ try {
3340
+ const token = await this.credentials.token();
3341
+ if (!token)
3342
+ return found;
3343
+ const response = await fetch(`${this.credentials.origin()}/v1/repositories/${repositoryId}/objects/chunk-delta/signatures`, {
3344
+ method: "POST",
3345
+ headers: {
3346
+ accept: "application/vnd.coderook.delta-signature-batch; version=2",
3347
+ authorization: `Bearer ${token}`,
3348
+ "content-type": "application/json",
3349
+ "user-agent": "CodeRook/0.1",
3350
+ ...(0, identify_js_1.clientHeaders)(),
3351
+ },
3352
+ body: JSON.stringify({
3353
+ baseVersionId: base.baseVersionId,
3354
+ path,
3355
+ baseSha256: base.baseSha256,
3356
+ ranges: group.map(({ start, end }) => ({ start, end })),
3357
+ }),
3358
+ signal: this.controller.signal,
3359
+ });
3360
+ if (!response.ok)
3361
+ continue;
3362
+ const bytes = new Uint8Array(await response.arrayBuffer());
3363
+ this.chunkDeltaSignatureBytes += bytes.byteLength;
3364
+ const decoded = (0, delta_js_1.decodeDeltaSignatureBatch)(bytes);
3365
+ if (decoded.length !== group.length)
3366
+ continue;
3367
+ group.forEach((one, index) => found.set(one.at, { signature: decoded[index], start: one.start, end: one.end }));
3368
+ }
3369
+ catch {
3370
+ /* Declined or unreachable: those pieces go whole, as they always did. */
3371
+ }
3372
+ }
3373
+ return found;
3374
+ }
3375
+ /** Signature bytes fetched for chunk deltas during this run, counted in what travelled. */
3376
+ chunkDeltaSignatureBytes = 0;
3377
+ /** One changed piece as a patch; null when there is no signature, the patch would not pay, or the service refuses. */
3378
+ async putChunkDelta(repositoryId, declaration, base, bytes, digest, signed) {
3379
+ if (!signed || this.planning)
3380
+ return null;
3381
+ try {
3382
+ const raw = (0, delta_js_1.createDeltaPatch)(bytes, signed.signature);
3383
+ /* A patch that is mostly new bytes of one kind compresses well; sent packed only when it clearly saves. */
3384
+ const packed = raw.byteLength > 1024 ? new Uint8Array((0, node_zlib_1.gzipSync)(raw, { level: 6 })) : null;
3385
+ const usePacked = packed !== null && packed.byteLength < raw.byteLength * 0.9;
3386
+ const patch = usePacked ? packed : raw;
3387
+ const manifest = new TextEncoder().encode(JSON.stringify({
3388
+ baseVersionId: base.baseVersionId,
3389
+ path: declaration.path,
3390
+ baseSha256: base.baseSha256,
3391
+ start: signed.start,
3392
+ end: signed.end,
3393
+ sha256: digest,
3394
+ size: bytes.byteLength,
3395
+ mediaType: declaration.mediaType,
3396
+ patchEncoding: usePacked ? "gzip" : "identity",
3397
+ }));
3398
+ const body = new Uint8Array(4 + manifest.byteLength + patch.byteLength);
3399
+ new DataView(body.buffer).setUint32(0, manifest.byteLength, false);
3400
+ body.set(manifest, 4);
3401
+ body.set(patch, 4 + manifest.byteLength);
3402
+ /* Worth it only when clearly smaller than the piece itself, the same margin the file deltas use. */
3403
+ if (body.byteLength >= bytes.byteLength * 0.8)
3404
+ return null;
3405
+ const answer = await this.call(`/v1/repositories/${repositoryId}/objects/chunk-delta`, { method: "POST", contentType: "application/octet-stream", body });
3406
+ if (!answer?.objectId)
3407
+ return null;
3408
+ (0, profile_js_1.counted)("chunks sent as deltas", 1);
3409
+ return { objectId: answer.objectId, storedSize: answer.storedSize, sentBytes: body.byteLength };
3410
+ }
3411
+ catch {
3412
+ return null;
3413
+ }
3414
+ }
2926
3415
  async sendChunk(repositoryId, declaration, piece, digest,
2927
3416
  /*
2928
3417
  Written at a known position rather than appended. The chunks are sent
@@ -210,7 +210,22 @@ async function push(repository, options) {
210
210
  }
211
211
  await (0, promises_1.rm)(scratch, { recursive: true, force: true });
212
212
  await (0, snapshot_js_1.writeEntriesInto)(repository, scratch, changed.map((file) => after.get(file)));
213
+ /*
214
+ This history holds the base of every file it changes, as Git's sender
215
+ does, so changed files go up as exact patches with no signatures
216
+ fetched first.
217
+ */
218
+ const baseTree = before;
213
219
  const request = {
220
+ /* The listing of each version pushed is kept in the history, for the next push. */
221
+ listingRoot: repository.directory,
222
+ localBase: async (file, sha256) => {
223
+ const entry = baseTree.get(file);
224
+ if (!entry || entry.sha256 !== sha256)
225
+ return null;
226
+ const parts = await Promise.all(entry.chunks.map((name) => repository.objects.get(name)));
227
+ return new Uint8Array(Buffer.concat(parts));
228
+ },
214
229
  localPath: scratch,
215
230
  include: [...changed, ...changes.deleted],
216
231
  deletions: changes.deleted,
@@ -18,6 +18,7 @@ exports.deltaBlockSize = deltaBlockSize;
18
18
  exports.estimateDeltaSignatureBytes = estimateDeltaSignatureBytes;
19
19
  exports.createDeltaSignature = createDeltaSignature;
20
20
  exports.parseDeltaSignature = parseDeltaSignature;
21
+ exports.createExactDeltaPatch = createExactDeltaPatch;
21
22
  exports.createDeltaPatch = createDeltaPatch;
22
23
  exports.deltaPatchTargetSize = deltaPatchTargetSize;
23
24
  exports.applyDeltaPatch = applyDeltaPatch;
@@ -313,6 +314,105 @@ function encodePatchV2(targetSize, operations) {
313
314
  }
314
315
  return out;
315
316
  }
317
+ /* Bytes a run must share with the base before it is worth a COPY over a literal. */
318
+ const EXACT_MIN_COPY = 12;
319
+ const EXACT_WINDOW = 16;
320
+ function windowKey(bytes, at) {
321
+ let hash = 0x811c9dc5;
322
+ for (let index = 0; index < EXACT_WINDOW; index += 1) {
323
+ hash = Math.imul(hash ^ (bytes[at + index] ?? 0), 0x01000193);
324
+ }
325
+ return hash >>> 0;
326
+ }
327
+ /**
328
+ * Build a V2 patch from the base bytes themselves, for a client that still
329
+ * holds the version it is editing, the way Git's sender holds the objects the
330
+ * receiver has. No signature is fetched, and copies start and end on any
331
+ * byte, so a one-byte edit costs a few bytes instead of a whole block.
332
+ *
333
+ * The result is the same CRP2 format the receiver-signature path produces:
334
+ * any service that applies those applies this, and verifies the digest after.
335
+ */
336
+ function createExactDeltaPatch(base, target) {
337
+ if (target.byteLength > exports.DELTA_MAX_FILE_BYTES || base.byteLength > exports.DELTA_MAX_FILE_BYTES) {
338
+ throw new Error("Delta target exceeds the bounded transport limit");
339
+ }
340
+ const operations = [];
341
+ /* The common case is one edited region: keep both ends whole. */
342
+ const limit = Math.min(base.byteLength, target.byteLength);
343
+ let prefix = 0;
344
+ while (prefix < limit && base[prefix] === target[prefix])
345
+ prefix += 1;
346
+ let suffix = 0;
347
+ while (suffix < limit - prefix &&
348
+ base[base.byteLength - 1 - suffix] === target[target.byteLength - 1 - suffix])
349
+ suffix += 1;
350
+ if (prefix >= EXACT_MIN_COPY)
351
+ appendOperation(operations, { kind: "copy", offset: 0, length: prefix });
352
+ else
353
+ prefix = 0;
354
+ if (suffix < EXACT_MIN_COPY)
355
+ suffix = 0;
356
+ const baseEnd = base.byteLength - suffix;
357
+ const targetEnd = target.byteLength - suffix;
358
+ /* Every window of the base's middle, by the offset it starts at. */
359
+ const index = new Map();
360
+ /*
361
+ Every offset for a small middle; every sixteenth past 256 KiB, as Git's
362
+ delta indexes, so an 8 MiB base is half a million entries, not eight
363
+ million. The target is still scanned at every byte, so moved text is found.
364
+ */
365
+ const stride = baseEnd - prefix > 256 * 1024 ? EXACT_WINDOW : 1;
366
+ for (let at = prefix; at + EXACT_WINDOW <= baseEnd; at += stride) {
367
+ const key = windowKey(base, at);
368
+ const list = index.get(key);
369
+ if (!list)
370
+ index.set(key, [at]);
371
+ else if (list.length < 16)
372
+ list.push(at);
373
+ }
374
+ let cursor = prefix;
375
+ let literalStart = prefix;
376
+ while (cursor + EXACT_WINDOW <= targetEnd) {
377
+ const candidates = index.get(windowKey(target, cursor));
378
+ let bestOffset = -1;
379
+ let bestLength = 0;
380
+ let bestBack = 0;
381
+ for (const offset of candidates ?? []) {
382
+ let length = 0;
383
+ while (cursor + length < targetEnd &&
384
+ offset + length < baseEnd &&
385
+ base[offset + length] === target[cursor + length])
386
+ length += 1;
387
+ let back = 0;
388
+ while (back < cursor - literalStart &&
389
+ back < offset - prefix &&
390
+ base[offset - back - 1] === target[cursor - back - 1])
391
+ back += 1;
392
+ if (length + back > bestLength + bestBack) {
393
+ bestOffset = offset;
394
+ bestLength = length;
395
+ bestBack = back;
396
+ }
397
+ }
398
+ if (bestOffset >= 0 && bestLength + bestBack >= EXACT_MIN_COPY) {
399
+ appendOperation(operations, { kind: "literal", bytes: target.slice(literalStart, cursor - bestBack) });
400
+ appendOperation(operations, {
401
+ kind: "copy",
402
+ offset: bestOffset - bestBack,
403
+ length: bestLength + bestBack,
404
+ });
405
+ cursor += bestLength;
406
+ literalStart = cursor;
407
+ continue;
408
+ }
409
+ cursor += 1;
410
+ }
411
+ appendOperation(operations, { kind: "literal", bytes: target.slice(literalStart, targetEnd) });
412
+ if (suffix)
413
+ appendOperation(operations, { kind: "copy", offset: baseEnd, length: suffix });
414
+ return encodePatchV2(target.byteLength, operations);
415
+ }
316
416
  /** Build COPY/INSERT operations from receiver signatures, without base bytes. */
317
417
  function createDeltaPatch(target, signature) {
318
418
  if (target.byteLength > exports.DELTA_MAX_FILE_BYTES) {
@@ -1267,14 +1267,27 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
1267
1267
  command now does it itself, a few times, before giving up.
1268
1268
  */
1269
1269
  const resumeAfter = [10, 30, 60];
1270
+ /*
1271
+ The project an attempt created, for the attempts after it. A first save
1272
+ creates its project while planning, and the project stays once anything
1273
+ has been sent so a retry can reuse what arrived; but the retry used to ask
1274
+ to create it again under the same name and was refused, so a dropped
1275
+ connection during a first save could never be picked up.
1276
+ */
1277
+ let createdRepositoryId = null;
1270
1278
  for (let resumed = 0;; resumed += 1) {
1271
1279
  try {
1280
+ if (createdRepositoryId && !uploadRequest.repositoryId) {
1281
+ Object.assign(uploadRequest, { repositoryId: createdRepositoryId });
1282
+ }
1272
1283
  const plan = await uploader.plan(uploadRequest, (progress) => {
1273
1284
  line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
1274
1285
  `${"plan".padEnd(7)} ${progress.files}/${progress.totalFiles} ` +
1275
1286
  dim(progress.path.slice(-40)));
1276
1287
  });
1277
1288
  done(line);
1289
+ if (plan.repositoryCreated)
1290
+ createdRepositoryId = plan.repositoryId;
1278
1291
  console.log(`${accent("Upload plan")} ${bytes(plan.sourceBytes)} selected → ` +
1279
1292
  `${bytes(plan.compactedBytes)} compacted; ${bytes(plan.chargeableBytes)} new storage.`);
1280
1293
  console.log(plan.allowance.exempt
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.46.1",
3
+ "version": "0.46.2",
4
4
  "description": "The command line for CodeRook (coderook.com): version control and project hosting for projects of any size. Every save is a compressed snapshot, private or public, never used to train AI.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://coderook.com",