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