naro-cli 0.14.0 → 0.16.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/dist/index.js CHANGED
@@ -26,7 +26,6 @@ import {
26
26
  controlPlaneUrl,
27
27
  countLintedTables,
28
28
  createAuthClient,
29
- dataError,
30
29
  deployDirectory,
31
30
  detectGitInfo,
32
31
  ensureGitignoreHasNaro,
@@ -83,7 +82,7 @@ import {
83
82
  toNaroError,
84
83
  unreadablePolicyComment,
85
84
  writeProjectLink
86
- } from "./chunk-U7AZSAFS.js";
85
+ } from "./chunk-XNT7SINN.js";
87
86
  import "./chunk-K3NQKI34.js";
88
87
 
89
88
  // src/args.ts
@@ -289,6 +288,13 @@ var Output = class {
289
288
  this.streams.stderr(`${message}
290
289
  `);
291
290
  }
291
+ /**
292
+ * Text on stderr exactly as given, with no line break added: a progress
293
+ * line a terminal rewrites in place (`\r…`). Never stdout, in either mode.
294
+ */
295
+ progress(text) {
296
+ this.streams.stderr(text);
297
+ }
292
298
  /** Extra diagnostics, only with --debug. */
293
299
  debugLog(message) {
294
300
  if (this.debug) this.streams.stderr(`[debug] ${message}
@@ -5272,7 +5278,7 @@ function parseSize(text) {
5272
5278
  throw new NaroError("VALIDATION_FAILED", SIZE_MESSAGE);
5273
5279
  return bytes;
5274
5280
  }
5275
- function binarySize(bytes) {
5281
+ function binarySize(bytes, decimals = 1) {
5276
5282
  const units = ["B", "KiB", "MiB", "GiB", "TiB"];
5277
5283
  let value = bytes;
5278
5284
  let unit = 0;
@@ -5280,7 +5286,7 @@ function binarySize(bytes) {
5280
5286
  value /= 1024;
5281
5287
  unit += 1;
5282
5288
  }
5283
- const shown = unit === 0 ? String(value) : String(Number(value.toFixed(1)));
5289
+ const shown = unit === 0 ? String(value) : String(Number(value.toFixed(decimals)));
5284
5290
  return `${shown} ${units[unit] ?? "B"}`;
5285
5291
  }
5286
5292
  function count(n, noun) {
@@ -5308,6 +5314,51 @@ function bucketLine(bucket) {
5308
5314
  const line = ` ${cells.join(" ")}`;
5309
5315
  return bucket.deletingAt === null ? line : `${line} (being removed: \`naro storage bucket remove ${name} --empty\` finishes it)`;
5310
5316
  }
5317
+ var WARN_PERCENT = 80;
5318
+ function utcClock(iso) {
5319
+ const at = new Date(iso);
5320
+ return Number.isNaN(at.getTime()) ? null : at.toISOString().slice(11, 16);
5321
+ }
5322
+ function warnedPercent(value, limit) {
5323
+ if (limit === null || !(limit > 0)) return null;
5324
+ const percent2 = Math.floor(value * 100 / limit);
5325
+ return percent2 >= WARN_PERCENT ? percent2 : null;
5326
+ }
5327
+ function meterLines(info) {
5328
+ const { today, limits } = info;
5329
+ if (today === null) return [];
5330
+ const reads = count(today.classBOps, "read");
5331
+ const writes = today.classAOps.toLocaleString("en-US");
5332
+ const dailyOps = limits.dailyOps?.toLocaleString("en-US") ?? null;
5333
+ const lines = [
5334
+ dailyOps === null ? `Today (UTC): ${writes} write operations, ${reads}` : `Today (UTC): ${writes} of ${dailyOps} write operations, ${reads}`
5335
+ ];
5336
+ if (today.measuredBytes === null)
5337
+ lines.push("Measured: not yet (the meter reads R2 every 15 minutes)");
5338
+ else {
5339
+ const time = today.measuredAt === null ? null : utcClock(today.measuredAt);
5340
+ lines.push(
5341
+ `Measured: ${binarySize(today.measuredBytes, 2)} in R2${time === null ? "" : ` at ${time} UTC`}`
5342
+ );
5343
+ }
5344
+ if (today.bytesBlocked)
5345
+ lines.push(
5346
+ today.measuredBytes === null ? `\u2717 Uploads are stopped: the bucket is at the storage limit of ${binarySize(limits.bytes)}. Delete objects; uploads reopen after the next measurement (usually within an hour and a half).` : `\u2717 Uploads are stopped: R2 holds ${binarySize(today.measuredBytes, 2)} of ${binarySize(limits.bytes)}. Delete objects; uploads reopen after the next measurement (usually within an hour and a half).`
5347
+ );
5348
+ if (today.opsBlocked)
5349
+ lines.push(
5350
+ dailyOps === null ? "\u2717 Uploads are stopped until 00:00 UTC: today's write operations are used. Downloads still work." : `\u2717 Uploads are stopped until 00:00 UTC: today's ${dailyOps} write operations are used. Downloads still work.`
5351
+ );
5352
+ const storage = today.bytesBlocked || today.measuredBytes === null ? null : warnedPercent(today.measuredBytes, limits.bytes);
5353
+ if (storage !== null)
5354
+ lines.push(`\u26A0 ${String(storage)}% of the storage limit is used.`);
5355
+ const operations = today.opsBlocked ? null : warnedPercent(today.classAOps, limits.dailyOps);
5356
+ if (operations !== null)
5357
+ lines.push(
5358
+ `\u26A0 ${String(operations)}% of today's write operations are used.`
5359
+ );
5360
+ return lines;
5361
+ }
5311
5362
  function storageLines(info) {
5312
5363
  if (!info.enabled)
5313
5364
  return [
@@ -5317,8 +5368,9 @@ function storageLines(info) {
5317
5368
  const { limits, usage } = info;
5318
5369
  return [
5319
5370
  `Storage: on (R2 bucket ${printable(info.bucket ?? "unknown")})`,
5320
- `Limits: ${binarySize(limits.bytes)} in all, ${binarySize(limits.objectBytes)} per object, ${binarySize(limits.singleUploadBytes)} per upload`,
5371
+ `Limits: ${binarySize(limits.bytes)} in all, ${binarySize(limits.objectBytes)} per object, ${binarySize(limits.singleUploadBytes)} per request (larger files go in parts)`,
5321
5372
  usage === null ? "Used: not known yet: the backend is not ready (`naro backend` shows where it got to)" : `Used: ${binarySize(usage.bytes)} in ${count(usage.objects, "object")}`,
5373
+ ...meterLines(info),
5322
5374
  info.buckets.length === 0 ? "Buckets: none yet (`naro storage bucket create <name>` makes one)" : `Buckets: ${String(info.buckets.length)}`,
5323
5375
  ...info.buckets.map(bucketLine)
5324
5376
  ];
@@ -5503,6 +5555,12 @@ function requestTimeout(ms) {
5503
5555
  message: `The Naro backend did not answer within ${String(Math.round(ms / 1e3))} seconds. Check the network and try again.`
5504
5556
  };
5505
5557
  }
5558
+ function uploadAborted() {
5559
+ return {
5560
+ code: "ABORTED",
5561
+ message: "The upload was stopped before it finished."
5562
+ };
5563
+ }
5506
5564
  function invalidResponse(status) {
5507
5565
  return {
5508
5566
  code: "INVALID_RESPONSE",
@@ -5668,6 +5726,12 @@ async function send(config, options) {
5668
5726
  credentials: "omit"
5669
5727
  };
5670
5728
  if (options.body !== void 0) init.body = options.body;
5729
+ const { signal } = options;
5730
+ if (signal?.aborted) return { error: uploadAborted(), status: null };
5731
+ const controller = signal ? new AbortController() : null;
5732
+ const stop = () => controller?.abort();
5733
+ if (controller) init.signal = controller.signal;
5734
+ signal?.addEventListener("abort", stop);
5671
5735
  let response;
5672
5736
  try {
5673
5737
  const fetchImpl = config.fetch ?? globalThis.fetch;
@@ -5676,26 +5740,34 @@ async function send(config, options) {
5676
5740
  init
5677
5741
  );
5678
5742
  } catch (error2) {
5679
- return { error: networkError(error2) };
5743
+ return {
5744
+ error: signal?.aborted ? uploadAborted() : networkError(error2),
5745
+ status: null
5746
+ };
5747
+ } finally {
5748
+ signal?.removeEventListener("abort", stop);
5680
5749
  }
5681
- if (response.status >= 200 && response.status < 300)
5682
- return { error: null, response };
5750
+ const { status } = response;
5751
+ if (status >= 200 && status < 300) return { error: null, response };
5683
5752
  let text;
5684
5753
  try {
5685
5754
  text = await response.text();
5686
5755
  } catch (error2) {
5687
- return { error: networkError(error2) };
5756
+ return { error: networkError(error2), status: null };
5688
5757
  }
5689
5758
  let body;
5690
5759
  if (text.trim() !== "") {
5691
5760
  try {
5692
5761
  body = JSON.parse(text);
5693
5762
  } catch {
5694
- return { error: invalidResponse(response.status) };
5763
+ return { error: invalidResponse(status), status };
5695
5764
  }
5696
5765
  }
5697
- const error = errorFromBody(response.status, body) ?? invalidResponse(response.status);
5698
- return { error: await settled(config, error, response.headers, token) };
5766
+ const error = errorFromBody(status, body) ?? invalidResponse(status);
5767
+ return {
5768
+ error: await settled(config, error, response.headers, token),
5769
+ status
5770
+ };
5699
5771
  }
5700
5772
  var RETRY_AFTER_CODES = /* @__PURE__ */ new Set([
5701
5773
  "RATE_LIMITED",
@@ -7266,10 +7338,176 @@ var TableBuilder = class {
7266
7338
  });
7267
7339
  }
7268
7340
  };
7341
+ function fail(error) {
7342
+ return { data: null, error };
7343
+ }
7344
+ var isUploadResult = (value) => isRecord(value) && typeof value.id === "string" && typeof value.path === "string" && typeof value.fullPath === "string";
7345
+ function dataOf2(status, body, guard) {
7346
+ if (isRecord(body) && Object.hasOwn(body, "data") && guard(body.data))
7347
+ return { data: body.data, error: null };
7348
+ return fail(invalidResponse(status));
7349
+ }
7350
+ async function readJson(response, guard) {
7351
+ let text;
7352
+ try {
7353
+ text = await response.text();
7354
+ } catch (error) {
7355
+ return fail(networkError(error));
7356
+ }
7357
+ let body;
7358
+ try {
7359
+ body = JSON.parse(text);
7360
+ } catch {
7361
+ return fail(invalidResponse(response.status));
7362
+ }
7363
+ return dataOf2(response.status, body, guard);
7364
+ }
7365
+ var ROUTE = "/storage/upload/multipart";
7366
+ var CONCURRENCY = 4;
7367
+ var ATTEMPTS = 4;
7368
+ var BACKOFF_MS = [1e3, 2e3, 4e3];
7369
+ var UPLOAD_ID = /^[\w-][\w.-]*$/;
7370
+ var positive = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
7371
+ var planFor = (size) => (value) => isRecord(value) && typeof value.uploadId === "string" && UPLOAD_ID.test(value.uploadId) && positive(value.partSize) && positive(value.partCount) && (value.partCount - 1) * value.partSize < size && value.partCount * value.partSize >= size;
7372
+ var partAnswer = (n) => (value) => isRecord(value) && value.partNumber === n && typeof value.etag === "string" && value.etag !== "";
7373
+ var retryable = (error, status) => error.code === "NETWORK_ERROR" || error.code === "OBJECT_RATE_LIMITED" || status !== null && status >= 500 && error.code !== "STORAGE_QUOTA_EXCEEDED";
7374
+ function waitAfter(error, attempt22) {
7375
+ const after = isRecord(error.details) ? error.details.retryAfter : void 0;
7376
+ if (typeof after === "number") return after * 1e3;
7377
+ if (error.code === "OBJECT_RATE_LIMITED") return 1e3;
7378
+ return BACKOFF_MS[attempt22 - 1] ?? 4e3;
7379
+ }
7380
+ var sleepFor = (ms, signal) => new Promise((done) => {
7381
+ if (signal?.aborted) {
7382
+ done();
7383
+ return;
7384
+ }
7385
+ const end = () => {
7386
+ clearTimeout(timer);
7387
+ signal?.removeEventListener("abort", end);
7388
+ done();
7389
+ };
7390
+ const timer = setTimeout(end, ms);
7391
+ signal?.addEventListener("abort", end);
7392
+ });
7393
+ async function uploadInParts(a) {
7394
+ const { signal } = a;
7395
+ const sleep2 = a.sleep ?? sleepFor;
7396
+ if (signal?.aborted) return fail(uploadAborted());
7397
+ const started = await call(a.http, {
7398
+ method: a.method,
7399
+ path: ROUTE,
7400
+ body: {
7401
+ bucket: a.bucket,
7402
+ path: a.path,
7403
+ size: a.size,
7404
+ contentType: a.contentType,
7405
+ cacheControl: a.cacheControl,
7406
+ // PUT always replaces: the worker refuses `upsert` on it.
7407
+ ...a.method === "POST" && a.upsert ? { upsert: true } : {}
7408
+ },
7409
+ withToken: true
7410
+ });
7411
+ if (started.error !== null) return fail(started.error);
7412
+ const plan = dataOf2(
7413
+ started.answer.status,
7414
+ started.answer.body,
7415
+ planFor(a.size)
7416
+ );
7417
+ if (plan.error !== null) return fail(plan.error);
7418
+ const { uploadId, partSize, partCount } = plan.data;
7419
+ const base = `${ROUTE}/${uploadId}`;
7420
+ const progress = (sentBytes2) => {
7421
+ if (signal?.aborted) return;
7422
+ try {
7423
+ const result = a.onProgress?.(sentBytes2, a.size);
7424
+ if (isThenable(result)) result.then(void 0, () => void 0);
7425
+ } catch {
7426
+ }
7427
+ };
7428
+ const callOff = async () => {
7429
+ await call(a.http, { method: "DELETE", path: base, withToken: true }).catch(
7430
+ () => void 0
7431
+ );
7432
+ };
7433
+ const etags = [];
7434
+ const failure = {
7435
+ error: signal?.aborted ? uploadAborted() : null
7436
+ };
7437
+ let next = 1;
7438
+ let sentBytes = 0;
7439
+ const onAbort = () => {
7440
+ failure.error ??= uploadAborted();
7441
+ };
7442
+ const tryPart = async (n, bytes) => {
7443
+ const sent = await send(a.http, {
7444
+ method: "PUT",
7445
+ path: `${base}/${String(n)}`,
7446
+ headers: { "content-type": "application/octet-stream" },
7447
+ body: bytes,
7448
+ withToken: true,
7449
+ signal
7450
+ });
7451
+ if (sent.error !== null) return sent;
7452
+ const read = await readJson(sent.response, partAnswer(n));
7453
+ if (read.error !== null)
7454
+ return { error: read.error, status: sent.response.status };
7455
+ return { etag: read.data.etag };
7456
+ };
7457
+ const sendPart = async (n) => {
7458
+ const from = (n - 1) * partSize;
7459
+ const to = Math.min(from + partSize, a.size);
7460
+ const bytes = a.body instanceof Uint8Array ? a.body.subarray(from, to) : a.body.slice(from, to);
7461
+ for (let attempt22 = 1; ; attempt22 += 1) {
7462
+ const tried = await tryPart(n, bytes);
7463
+ if ("etag" in tried) {
7464
+ etags[n - 1] = tried.etag;
7465
+ sentBytes += to - from;
7466
+ progress(sentBytes);
7467
+ return null;
7468
+ }
7469
+ if (attempt22 === ATTEMPTS || failure.error !== null || !retryable(tried.error, tried.status))
7470
+ return tried.error;
7471
+ await sleep2(waitAfter(tried.error, attempt22), signal);
7472
+ if (failure.error !== null) return tried.error;
7473
+ }
7474
+ };
7475
+ const worker = async () => {
7476
+ while (failure.error === null && next <= partCount) {
7477
+ const n = next;
7478
+ next += 1;
7479
+ const error = await sendPart(n);
7480
+ if (error !== null) failure.error ??= error;
7481
+ }
7482
+ };
7483
+ signal?.addEventListener("abort", onAbort);
7484
+ await Promise.all(
7485
+ Array.from({ length: Math.min(CONCURRENCY, partCount) }, worker)
7486
+ );
7487
+ signal?.removeEventListener("abort", onAbort);
7488
+ if (failure.error !== null) {
7489
+ await callOff();
7490
+ return fail(failure.error);
7491
+ }
7492
+ const completed = await call(a.http, {
7493
+ method: "POST",
7494
+ path: `${base}/complete`,
7495
+ body: {
7496
+ parts: etags.map((etag, i) => ({ partNumber: i + 1, etag }))
7497
+ },
7498
+ withToken: true
7499
+ });
7500
+ if (completed.error !== null) {
7501
+ if (completed.answer !== null && completed.error.code !== "INVALID_RESPONSE")
7502
+ await callOff();
7503
+ return fail(completed.error);
7504
+ }
7505
+ return dataOf2(completed.answer.status, completed.answer.body, isUploadResult);
7506
+ }
7507
+ var isThenable = (value) => typeof value === "object" && value !== null && typeof value.then === "function";
7269
7508
  var MAX_SINGLE_UPLOAD_BYTES2 = 52428800;
7270
7509
  var STORAGE_MESSAGES = {
7271
7510
  bucket: "The bucket name is not valid.",
7272
- tooLarge: "Uploads over 50 MiB are not supported yet.",
7273
7511
  request: "The storage request is not valid.",
7274
7512
  body: "upload takes a Blob, File, ArrayBuffer, typed array or string."
7275
7513
  };
@@ -7294,9 +7532,6 @@ function invalidPath(reason) {
7294
7532
  message: `The object path is not valid: it ${reason}.`
7295
7533
  };
7296
7534
  }
7297
- function objectTooLarge() {
7298
- return { code: "OBJECT_TOO_LARGE", message: STORAGE_MESSAGES.tooLarge };
7299
- }
7300
7535
  function encodePath(path) {
7301
7536
  if (typeof path !== "string" || path === "")
7302
7537
  return { ok: false, error: invalidPath("is empty") };
@@ -7333,6 +7568,16 @@ function encodeBucket(bucket) {
7333
7568
  };
7334
7569
  return { ok: true, encoded: encodeURIComponent(bucket) };
7335
7570
  }
7571
+ function objectRoute(bucket, path) {
7572
+ const encodedBucket = encodeBucket(bucket);
7573
+ if (!encodedBucket.ok) return encodedBucket;
7574
+ const encodedPath = encodePath(path);
7575
+ if (!encodedPath.ok) return encodedPath;
7576
+ return {
7577
+ ok: true,
7578
+ encoded: `${encodedBucket.encoded}/${encodedPath.encoded}`
7579
+ };
7580
+ }
7336
7581
  var isArrayBuffer = (value) => Object.prototype.toString.call(value) === "[object ArrayBuffer]";
7337
7582
  function isDetached(buffer) {
7338
7583
  if (!isArrayBuffer(buffer)) return false;
@@ -7413,36 +7658,12 @@ function payloadOf(body, contentType) {
7413
7658
  };
7414
7659
  }
7415
7660
  var SIGNED_PREFIX = "/_naro/v1/storage/object/sign/";
7416
- function fail(error) {
7417
- return { data: null, error };
7418
- }
7419
- var isUploadResult = (value) => isRecord(value) && typeof value.id === "string" && typeof value.path === "string" && typeof value.fullPath === "string";
7420
7661
  var isFileObject = (value) => isRecord(value) && typeof value.id === "string" && typeof value.name === "string" && isRecord(value.metadata);
7421
7662
  var isEntry2 = (value) => isRecord(value) && typeof value.name === "string" && (value.id === null || isFileObject(value));
7422
7663
  var isSigned = (value) => isRecord(value) && typeof value.signedUrl === "string" && value.signedUrl.startsWith(SIGNED_PREFIX);
7423
7664
  function listOf(guard) {
7424
7665
  return (value) => Array.isArray(value) && value.every(guard);
7425
7666
  }
7426
- function dataOf2(status, body, guard) {
7427
- if (isRecord(body) && Object.hasOwn(body, "data") && guard(body.data))
7428
- return { data: body.data, error: null };
7429
- return fail(invalidResponse(status));
7430
- }
7431
- async function readJson(response, guard) {
7432
- let text;
7433
- try {
7434
- text = await response.text();
7435
- } catch (error) {
7436
- return fail(networkError(error));
7437
- }
7438
- let body;
7439
- try {
7440
- body = JSON.parse(text);
7441
- } catch {
7442
- return fail(invalidResponse(response.status));
7443
- }
7444
- return dataOf2(response.status, body, guard);
7445
- }
7446
7667
  var StorageFileApi = class {
7447
7668
  #http;
7448
7669
  #auth;
@@ -7454,14 +7675,9 @@ var StorageFileApi = class {
7454
7675
  }
7455
7676
  /** `/storage/object/<route><bucket>/<path>`, or the refusal no request needs. */
7456
7677
  #objectPath(route2, path) {
7457
- const bucket = encodeBucket(this.#bucket);
7458
- if (!bucket.ok) return bucket;
7459
- const name = encodePath(path);
7460
- if (!name.ok) return name;
7461
- return {
7462
- ok: true,
7463
- path: `/storage/object/${route2}${bucket.encoded}/${name.encoded}`
7464
- };
7678
+ const target = objectRoute(this.#bucket, path);
7679
+ if (!target.ok) return target;
7680
+ return { ok: true, path: `/storage/object/${route2}${target.encoded}` };
7465
7681
  }
7466
7682
  /** `/storage/object/<route><bucket>`, for the routes that take names in the body. */
7467
7683
  #bucketPath(route2) {
@@ -7483,8 +7699,24 @@ var StorageFileApi = class {
7483
7699
  const cache = `max-age=${options.cacheControl ?? "3600"}`;
7484
7700
  const unreadable = headerProblem(options.contentType, cache);
7485
7701
  if (unreadable !== null) return fail(unreadable);
7486
- if (payload.size > MAX_SINGLE_UPLOAD_BYTES2) return fail(objectTooLarge());
7702
+ const { signal } = options;
7703
+ if (signal?.aborted) return fail(uploadAborted());
7487
7704
  await this.#auth.ready();
7705
+ if (payload.size > MAX_SINGLE_UPLOAD_BYTES2)
7706
+ return uploadInParts({
7707
+ http: this.#http,
7708
+ method,
7709
+ bucket: this.#bucket,
7710
+ path,
7711
+ // No copy: a Blob stays lazy, a Uint8Array is viewed, an ArrayBuffer wrapped.
7712
+ body: isArrayBuffer(payload.body) ? new Uint8Array(payload.body) : payload.body,
7713
+ size: payload.size,
7714
+ contentType: payload.type,
7715
+ cacheControl: cache,
7716
+ upsert,
7717
+ ...options.onProgress ? { onProgress: options.onProgress } : {},
7718
+ signal
7719
+ });
7488
7720
  const headers = {
7489
7721
  "content-type": payload.type,
7490
7722
  "cache-control": cache
@@ -7495,7 +7727,9 @@ var StorageFileApi = class {
7495
7727
  path: target.path,
7496
7728
  headers,
7497
7729
  body: payload.body,
7498
- withToken: true
7730
+ withToken: true,
7731
+ // The fetch stops with the body: the worker undoes its claim at once.
7732
+ signal
7499
7733
  });
7500
7734
  if (sent.error !== null) return fail(sent.error);
7501
7735
  return readJson(sent.response, isUploadResult);
@@ -7741,6 +7975,7 @@ var CP_USAGE = "Usage: naro storage cp <from> <to> (one side naro://<bucket>/<pa
7741
7975
  var RM_USAGE = "Usage: naro storage rm naro://<bucket>/<path>\u2026 [--recursive] [--yes]";
7742
7976
  var LS_USAGE = "Usage: naro storage ls [naro://<bucket>[/<prefix>]] [--recursive]";
7743
7977
  var BACKEND_OFF_MESSAGE = "The backend is turned off for this project. Turn it on with `naro backend enable` first.";
7978
+ var FOUR_GIB = 4294967296;
7744
7979
  var PAGE = 1e3;
7745
7980
  var MAX_OFFSET = 1e5;
7746
7981
  var TRUNCATED_WARNING = `\u26A0 A folder's listing stopped at ${(MAX_OFFSET + PAGE).toLocaleString("en-US")} entries, as far as the API's offset reaches; there may be more.`;
@@ -7843,7 +8078,8 @@ async function objects(context) {
7843
8078
  { cause: error }
7844
8079
  );
7845
8080
  }
7846
- return { projectId, storage: naro.storage };
8081
+ const url = new URL(backend.url.trim().replace(/\/+$/, "")).origin;
8082
+ return { projectId, storage: naro.storage, url, serviceKey };
7847
8083
  }
7848
8084
  function isFolder(entry) {
7849
8085
  return entry.id === null;
@@ -7936,33 +8172,56 @@ async function storageLs(context) {
7936
8172
  );
7937
8173
  }
7938
8174
  var INTERRUPTS = ["SIGINT", "SIGTERM", "SIGHUP"];
7939
- function removeOnInterrupt(partial) {
7940
- const stop = () => {
8175
+ function endWith(signal) {
8176
+ try {
8177
+ process.kill(process.pid, signal);
8178
+ } catch {
8179
+ process.exit(1);
8180
+ }
8181
+ }
8182
+ function onInterrupt(onSignal) {
8183
+ for (const signal of INTERRUPTS) process.on(signal, onSignal);
8184
+ return () => {
7941
8185
  for (const signal of INTERRUPTS) process.off(signal, onSignal);
7942
8186
  };
7943
- function onSignal(signal) {
8187
+ }
8188
+ function removeOnInterrupt(partial) {
8189
+ const stop = onInterrupt((signal) => {
7944
8190
  stop();
7945
8191
  try {
7946
8192
  rmSync2(partial, { force: true });
7947
8193
  } catch {
7948
8194
  }
7949
- try {
7950
- process.kill(process.pid, signal);
7951
- } catch {
7952
- process.exit(1);
7953
- }
7954
- }
7955
- for (const signal of INTERRUPTS) process.on(signal, onSignal);
8195
+ endWith(signal);
8196
+ });
7956
8197
  return stop;
7957
8198
  }
7958
- async function writeAtomically(blob, destination) {
8199
+ function stopOnInterrupt(onFirst) {
8200
+ const controller = new AbortController();
8201
+ let caught = null;
8202
+ const stop = onInterrupt((signal) => {
8203
+ if (caught !== null) {
8204
+ stop();
8205
+ endWith(signal);
8206
+ return;
8207
+ }
8208
+ caught = signal;
8209
+ onFirst();
8210
+ controller.abort();
8211
+ });
8212
+ return {
8213
+ signal: controller.signal,
8214
+ settle: () => {
8215
+ stop();
8216
+ return caught;
8217
+ }
8218
+ };
8219
+ }
8220
+ async function writeAtomically(bytes, destination) {
7959
8221
  const partial = join6(dirname3(destination), `.${randomUUID()}.naro-partial`);
7960
8222
  const stopListening = removeOnInterrupt(partial);
7961
8223
  try {
7962
- await pipeline2(
7963
- Readable.fromWeb(blob.stream()),
7964
- createWriteStream2(partial, { flags: "wx" })
7965
- );
8224
+ await pipeline2(bytes, createWriteStream2(partial, { flags: "wx" }));
7966
8225
  await rename(partial, destination);
7967
8226
  } catch (error) {
7968
8227
  await rm(partial, { force: true }).catch(() => void 0);
@@ -7971,6 +8230,34 @@ async function writeAtomically(blob, destination) {
7971
8230
  stopListening();
7972
8231
  }
7973
8232
  }
8233
+ function progressReporter(output, total) {
8234
+ const tty = process.stderr.isTTY === true;
8235
+ let lastTenth = -1;
8236
+ let lastLength = 0;
8237
+ let open = false;
8238
+ return {
8239
+ report(sent) {
8240
+ const percent2 = Math.floor(sent / total * 100);
8241
+ const line = `\u2191 ${binarySize(sent)} / ${binarySize(total)} (${String(percent2)}%)`;
8242
+ if (tty) {
8243
+ open = sent !== total;
8244
+ output.progress(`\r${line.padEnd(lastLength)}${open ? "" : "\n"}`);
8245
+ lastLength = line.length;
8246
+ return;
8247
+ }
8248
+ const tenth = Math.floor(percent2 / 10);
8249
+ if (tenth === lastTenth && sent !== total) return;
8250
+ lastTenth = tenth;
8251
+ output.progress(`${line}
8252
+ `);
8253
+ },
8254
+ close() {
8255
+ if (!open) return;
8256
+ open = false;
8257
+ output.progress("\n");
8258
+ }
8259
+ };
8260
+ }
7974
8261
  async function upload(context, local, target) {
7975
8262
  const file = resolve5(context.cwd, local);
7976
8263
  const info = await stat(file).catch(() => null);
@@ -7989,32 +8276,49 @@ async function upload(context, local, target) {
7989
8276
  "VALIDATION_FAILED",
7990
8277
  `${printable(local)} is not a regular file.`
7991
8278
  );
7992
- if (info.size > MAX_SINGLE_UPLOAD_BYTES)
7993
- throw new NaroError(
7994
- "OBJECT_TOO_LARGE",
7995
- dataError("OBJECT_TOO_LARGE", { single: true }).message
7996
- );
7997
8279
  const { cacheControl } = context.flags;
7998
8280
  if (cacheControl !== void 0 && !isMaxAge(cacheControl))
7999
8281
  throw new NaroError("VALIDATION_FAILED", CACHE_CONTROL_RULE);
8000
8282
  const path = target.path === "" || target.path.endsWith("/") ? `${target.path}${basename2(file)}` : target.path;
8001
- const { projectId, storage } = await objects(context);
8002
8283
  const body = await openAsBlob(file);
8284
+ if (body.size !== info.size)
8285
+ throw new NaroError(
8286
+ "VALIDATION_FAILED",
8287
+ info.size >= FOUR_GIB ? `${printable(local)} is ${binarySize(info.size)}, but Node reads it as ${binarySize(body.size)}: naro storage cp cannot upload a file of 4 GiB or more yet.` : `${printable(local)} changed while it was being opened (${binarySize(info.size)}, then ${binarySize(body.size)}). Run the copy again once nothing is writing to it.`
8288
+ );
8289
+ const { projectId, storage } = await objects(context);
8003
8290
  const contentType = context.flags.contentType ?? typeFor(file);
8004
- const result = unwrap(
8005
- await storage.from(target.bucket).upload(path, body, {
8291
+ const progress = body.size > MAX_SINGLE_UPLOAD_BYTES && !context.output.json ? progressReporter(context.output, body.size) : void 0;
8292
+ const interrupt = stopOnInterrupt(() => {
8293
+ progress?.close();
8294
+ context.output.progress("Stopping the upload\u2026\n");
8295
+ });
8296
+ let answer;
8297
+ let caught;
8298
+ try {
8299
+ answer = await storage.from(target.bucket).upload(path, body, {
8006
8300
  ...contentType === void 0 ? {} : { contentType },
8007
8301
  ...cacheControl === void 0 ? {} : { cacheControl },
8008
- upsert: context.flags.upsert === true
8009
- })
8010
- );
8011
- const to = `${NARO_SCHEME}${target.bucket}/${path}`;
8012
- context.output.result(
8013
- { projectId, from: local, to, bytes: body.size, id: result.id },
8014
- [
8015
- `\u2705 Uploaded ${printable(local)} \u2192 ${printable(to)} (${binarySize(body.size)})`
8016
- ]
8017
- );
8302
+ upsert: context.flags.upsert === true,
8303
+ ...progress === void 0 ? {} : { onProgress: progress.report },
8304
+ signal: interrupt.signal
8305
+ });
8306
+ } finally {
8307
+ progress?.close();
8308
+ caught = interrupt.settle();
8309
+ }
8310
+ try {
8311
+ const result = unwrap(answer);
8312
+ const to = `${NARO_SCHEME}${target.bucket}/${path}`;
8313
+ context.output.result(
8314
+ { projectId, from: local, to, bytes: body.size, id: result.id },
8315
+ [
8316
+ `\u2705 Uploaded ${printable(local)} \u2192 ${printable(to)} (${binarySize(body.size)})`
8317
+ ]
8318
+ );
8319
+ } finally {
8320
+ if (caught !== null) endWith(caught);
8321
+ }
8018
8322
  }
8019
8323
  var WINDOWS_DEVICE = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
8020
8324
  var MAX_FILE_NAME_BYTES = 255;
@@ -8027,6 +8331,62 @@ function savedAs(path) {
8027
8331
  );
8028
8332
  return name;
8029
8333
  }
8334
+ var NETWORK_MESSAGE = "The request did not reach the Naro backend. Check the url and the network.";
8335
+ async function failureOf(response) {
8336
+ const { status } = response;
8337
+ const invalid = {
8338
+ code: "INVALID_RESPONSE",
8339
+ message: `The server did not answer like a Naro backend (HTTP ${String(status)}).`,
8340
+ details: { status }
8341
+ };
8342
+ let text;
8343
+ try {
8344
+ text = await response.text();
8345
+ } catch {
8346
+ return { code: "NETWORK_ERROR", message: NETWORK_MESSAGE };
8347
+ }
8348
+ let body;
8349
+ if (text.trim() !== "") {
8350
+ try {
8351
+ body = JSON.parse(text);
8352
+ } catch {
8353
+ return invalid;
8354
+ }
8355
+ }
8356
+ const error = typeof body === "object" && body !== null && "error" in body ? body.error : void 0;
8357
+ if (typeof error !== "object" || error === null) return invalid;
8358
+ const { code, message, details } = error;
8359
+ if (typeof code !== "string" || code === "" || typeof message !== "string")
8360
+ return invalid;
8361
+ return details === void 0 ? { code, message } : { code, message, details };
8362
+ }
8363
+ async function getObject(url, serviceKey) {
8364
+ let response;
8365
+ try {
8366
+ response = await fetch(url, {
8367
+ headers: { apikey: serviceKey },
8368
+ redirect: "error"
8369
+ });
8370
+ } catch {
8371
+ throw new NaroError("NETWORK_ERROR", NETWORK_MESSAGE);
8372
+ }
8373
+ if (response.ok) return response;
8374
+ return unwrap({ data: null, error: await failureOf(response) });
8375
+ }
8376
+ async function* chunksOf2(response, received) {
8377
+ if (response.body === null) return;
8378
+ try {
8379
+ for await (const chunk2 of Readable.fromWeb(
8380
+ response.body
8381
+ )) {
8382
+ const bytes = chunk2;
8383
+ received.bytes += bytes.byteLength;
8384
+ yield bytes;
8385
+ }
8386
+ } catch {
8387
+ throw new NaroError("NETWORK_ERROR", NETWORK_MESSAGE);
8388
+ }
8389
+ }
8030
8390
  async function download(context, source, local) {
8031
8391
  if (source.path === "" || source.path.endsWith("/"))
8032
8392
  throw new NaroError(
@@ -8042,14 +8402,20 @@ async function download(context, source, local) {
8042
8402
  "VALIDATION_FAILED",
8043
8403
  `Cannot save to ${printable(local)}: its folder does not exist.`
8044
8404
  );
8045
- const { projectId, storage } = await objects(context);
8046
- const blob = unwrap(await storage.from(source.bucket).download(source.path));
8047
- await writeAtomically(blob, destination);
8405
+ const route2 = objectRoute(source.bucket, source.path);
8406
+ if (!route2.ok) throw new NaroError(route2.error.code, route2.error.message);
8407
+ const { projectId, url, serviceKey } = await objects(context);
8408
+ const response = await getObject(
8409
+ `${url}/_naro/v1/storage/object/${route2.encoded}`,
8410
+ serviceKey
8411
+ );
8412
+ const received = { bytes: 0 };
8413
+ await writeAtomically(chunksOf2(response, received), destination);
8048
8414
  const from = `${NARO_SCHEME}${source.bucket}/${source.path}`;
8049
8415
  context.output.result(
8050
- { projectId, from, to: destination, bytes: blob.size },
8416
+ { projectId, from, to: destination, bytes: received.bytes },
8051
8417
  [
8052
- `\u2705 Downloaded ${printable(from)} \u2192 ${printable(destination)} (${binarySize(blob.size)})`
8418
+ `\u2705 Downloaded ${printable(from)} \u2192 ${printable(destination)} (${binarySize(received.bytes)})`
8053
8419
  ]
8054
8420
  );
8055
8421
  }
@@ -8328,7 +8694,7 @@ Usage:
8328
8694
  naro_insert, naro_update, naro_delete (shorthand for CREATE POLICY)
8329
8695
  naro policy remove <table> Drop every policy on it: back to deny (asks first)
8330
8696
 
8331
- naro storage [status] Naro Storage: on or off, limits, what is stored, the buckets
8697
+ naro storage [status] Naro Storage: on or off, limits, what is stored, today's operations, the buckets
8332
8698
  naro storage enable Turn Storage on: the project's R2 bucket, bound to its backend
8333
8699
  (safe to re-run; applies a plan change's limits)
8334
8700
  naro storage bucket list The buckets: public or private, objects, bytes, limits
@@ -8342,8 +8708,9 @@ Usage:
8342
8708
  naro storage ls [naro://<bucket>[/<prefix>]] [--recursive]
8343
8709
  The buckets, or the folders and objects under a prefix
8344
8710
  naro storage cp <from> <to> [--upsert] [--content-type <t>] [--cache-control <seconds>]
8345
- One file between this machine and naro://<bucket>/<path>, 50 MiB at most,
8711
+ One file between this machine and naro://<bucket>/<path>, up to the object limit (over 50 MiB in parts),
8346
8712
  with the project's service key (NARO_SERVICE_KEY, or fetched with a warning).
8713
+ A big upload shows its progress on stderr; a file of 4 GiB or more cannot be uploaded yet.
8347
8714
  A download replaces a local file of the same name
8348
8715
  naro storage rm naro://<bucket>/<path>\u2026 [--recursive]
8349
8716
  Remove objects; --recursive removes what is under a folder (asks first):