@coderook/cli 0.46.0 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/cbx/src/core/upload.js +559 -70
- package/dist/cbx/src/local/remote.js +15 -0
- package/dist/cbx/src/shared/delta.js +100 -0
- package/dist/cbx/src/shared/native_engine.js +23 -1
- package/dist/cli/src/cli.js +13 -0
- package/engine-bin/darwin-arm64/cbx-engine +0 -0
- package/engine-bin/darwin-x64/cbx-engine +0 -0
- package/engine-bin/win32-x64/cbx-engine.exe +0 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
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
|
|
1183
|
-
const
|
|
1184
|
-
|
|
1185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
}
|
|
2451
|
-
|
|
2452
|
-
|
|
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
|
|
2463
|
-
|
|
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:
|
|
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
|
-
|
|
2543
|
-
const
|
|
2544
|
-
|
|
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
|
-
|
|
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 <
|
|
3065
|
+
for (let at = 0; at < asking.length; at += 5000) {
|
|
2707
3066
|
this.check();
|
|
2708
|
-
const batch = [...new Set(
|
|
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
|
-
|
|
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) {
|
|
@@ -96,13 +96,35 @@ function engineProblem() {
|
|
|
96
96
|
return broken;
|
|
97
97
|
return enginePath() ? null : "no engine for this platform";
|
|
98
98
|
}
|
|
99
|
-
|
|
99
|
+
/*
|
|
100
|
+
Whether the engine should open and read files itself (store, measure), as
|
|
101
|
+
opposed to only walking folders (scan).
|
|
102
|
+
|
|
103
|
+
Not on Windows, for now. With real-time protection on, which is nearly
|
|
104
|
+
everyone, Windows Security scans each new file when the unsigned engine
|
|
105
|
+
opens it and barely slows Node, a signed and widely known program. On a
|
|
106
|
+
fresh copy of 8,525 files the engine took 13–16 s to store what Node read
|
|
107
|
+
and stored in 3 s; with protection off the engine took 0.5 s. Until the
|
|
108
|
+
engine is signed, Windows reads files here and the engine keeps the folder
|
|
109
|
+
walk, which only lists and stats and is not scanned. CBX_ENGINE_READS=on
|
|
110
|
+
turns engine reads back on.
|
|
111
|
+
*/
|
|
112
|
+
function engineMayRead() {
|
|
113
|
+
if (process.env.CBX_ENGINE_READS === "on")
|
|
114
|
+
return true;
|
|
115
|
+
if (process.env.CBX_ENGINE_READS === "off")
|
|
116
|
+
return false;
|
|
117
|
+
return process.platform !== "win32";
|
|
118
|
+
}
|
|
119
|
+
/** Whether a batch of this size is worth handing to the engine to read. */
|
|
100
120
|
function worthTheEngine(files, bytes) {
|
|
101
121
|
if (enginePath() === null || broken || files === 0)
|
|
102
122
|
return false;
|
|
103
123
|
/* CBX_ENGINE_ALWAYS=1 sends every batch, however small, so tests can run everything through it. */
|
|
104
124
|
if (process.env.CBX_ENGINE_ALWAYS === "1")
|
|
105
125
|
return true;
|
|
126
|
+
if (!engineMayRead())
|
|
127
|
+
return false;
|
|
106
128
|
return files >= exports.ENGINE_MIN_FILES || bytes >= exports.ENGINE_MIN_BYTES;
|
|
107
129
|
}
|
|
108
130
|
function run(args, input, onLine, signal) {
|
package/dist/cli/src/cli.js
CHANGED
|
@@ -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.
|
|
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",
|