@ardrive/turbo-sdk 1.42.0 → 1.43.0-alpha.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.
Files changed (99) hide show
  1. package/lib/cjs/cli/cli.js +97 -0
  2. package/lib/cjs/cli/commands/arns.js +475 -0
  3. package/lib/cjs/cli/commands/freeStatus.js +47 -0
  4. package/lib/cjs/cli/commands/index.js +3 -0
  5. package/lib/cjs/cli/commands/paymentHistory.js +25 -0
  6. package/lib/cjs/cli/options.js +202 -1
  7. package/lib/cjs/common/arnsActions.js +125 -0
  8. package/lib/cjs/common/chunked.js +51 -1
  9. package/lib/cjs/common/folderIndex.js +401 -0
  10. package/lib/cjs/common/http.js +40 -6
  11. package/lib/cjs/common/index.js +7 -0
  12. package/lib/cjs/common/payment.js +548 -2
  13. package/lib/cjs/common/signer.js +27 -6
  14. package/lib/cjs/common/token/index.js +0 -11
  15. package/lib/cjs/common/turbo.js +162 -0
  16. package/lib/cjs/common/upload.js +314 -31
  17. package/lib/cjs/node/folderIndex.js +132 -0
  18. package/lib/cjs/node/index.js +1 -0
  19. package/lib/cjs/node/upload.js +7 -0
  20. package/lib/cjs/types.js +53 -3
  21. package/lib/cjs/utils/common.js +0 -4
  22. package/lib/cjs/utils/errors.js +38 -1
  23. package/lib/cjs/utils/folderIndex.js +88 -0
  24. package/lib/cjs/utils/uuid.js +31 -0
  25. package/lib/cjs/web/signer.js +4 -2
  26. package/lib/esm/cli/cli.js +99 -2
  27. package/lib/esm/cli/commands/arns.js +450 -0
  28. package/lib/esm/cli/commands/freeStatus.js +44 -0
  29. package/lib/esm/cli/commands/index.js +3 -0
  30. package/lib/esm/cli/commands/paymentHistory.js +22 -0
  31. package/lib/esm/cli/options.js +201 -0
  32. package/lib/esm/common/arnsActions.js +114 -0
  33. package/lib/esm/common/chunked.js +51 -1
  34. package/lib/esm/common/folderIndex.js +396 -0
  35. package/lib/esm/common/http.js +39 -5
  36. package/lib/esm/common/index.js +7 -0
  37. package/lib/esm/common/payment.js +548 -2
  38. package/lib/esm/common/signer.js +28 -7
  39. package/lib/esm/common/token/index.js +1 -12
  40. package/lib/esm/common/turbo.js +162 -0
  41. package/lib/esm/common/upload.js +316 -33
  42. package/lib/esm/node/folderIndex.js +129 -0
  43. package/lib/esm/node/index.js +1 -0
  44. package/lib/esm/node/upload.js +7 -0
  45. package/lib/esm/types.js +52 -2
  46. package/lib/esm/utils/common.js +0 -4
  47. package/lib/esm/utils/errors.js +35 -0
  48. package/lib/esm/utils/folderIndex.js +81 -0
  49. package/lib/esm/utils/uuid.js +28 -0
  50. package/lib/esm/web/signer.js +4 -2
  51. package/lib/types/cli/commands/arns.d.ts +147 -0
  52. package/lib/types/cli/commands/arns.d.ts.map +1 -0
  53. package/lib/types/cli/commands/freeStatus.d.ts +3 -0
  54. package/lib/types/cli/commands/freeStatus.d.ts.map +1 -0
  55. package/lib/types/cli/commands/index.d.ts +3 -0
  56. package/lib/types/cli/commands/index.d.ts.map +1 -1
  57. package/lib/types/cli/commands/paymentHistory.d.ts +18 -0
  58. package/lib/types/cli/commands/paymentHistory.d.ts.map +1 -0
  59. package/lib/types/cli/options.d.ts +423 -0
  60. package/lib/types/cli/options.d.ts.map +1 -1
  61. package/lib/types/cli/types.d.ts +78 -0
  62. package/lib/types/cli/types.d.ts.map +1 -1
  63. package/lib/types/common/arnsActions.d.ts +58 -0
  64. package/lib/types/common/arnsActions.d.ts.map +1 -0
  65. package/lib/types/common/chunked.d.ts +47 -2
  66. package/lib/types/common/chunked.d.ts.map +1 -1
  67. package/lib/types/common/folderIndex.d.ts +54 -0
  68. package/lib/types/common/folderIndex.d.ts.map +1 -0
  69. package/lib/types/common/http.d.ts +25 -2
  70. package/lib/types/common/http.d.ts.map +1 -1
  71. package/lib/types/common/index.d.ts +3 -0
  72. package/lib/types/common/index.d.ts.map +1 -1
  73. package/lib/types/common/payment.d.ts +291 -1
  74. package/lib/types/common/payment.d.ts.map +1 -1
  75. package/lib/types/common/signer.d.ts +12 -6
  76. package/lib/types/common/signer.d.ts.map +1 -1
  77. package/lib/types/common/token/index.d.ts.map +1 -1
  78. package/lib/types/common/turbo.d.ts +183 -1
  79. package/lib/types/common/turbo.d.ts.map +1 -1
  80. package/lib/types/common/upload.d.ts +41 -0
  81. package/lib/types/common/upload.d.ts.map +1 -1
  82. package/lib/types/node/folderIndex.d.ts +28 -0
  83. package/lib/types/node/folderIndex.d.ts.map +1 -0
  84. package/lib/types/node/index.d.ts +1 -0
  85. package/lib/types/node/index.d.ts.map +1 -1
  86. package/lib/types/node/upload.d.ts +1 -0
  87. package/lib/types/node/upload.d.ts.map +1 -1
  88. package/lib/types/types.d.ts +694 -3
  89. package/lib/types/types.d.ts.map +1 -1
  90. package/lib/types/utils/common.d.ts.map +1 -1
  91. package/lib/types/utils/errors.d.ts +29 -0
  92. package/lib/types/utils/errors.d.ts.map +1 -1
  93. package/lib/types/utils/folderIndex.d.ts +47 -0
  94. package/lib/types/utils/folderIndex.d.ts.map +1 -0
  95. package/lib/types/utils/uuid.d.ts +7 -0
  96. package/lib/types/utils/uuid.d.ts.map +1 -0
  97. package/lib/types/web/signer.d.ts +1 -1
  98. package/lib/types/web/signer.d.ts.map +1 -1
  99. package/package.json +1 -1
@@ -16,13 +16,14 @@
16
16
  import { BigNumber } from 'bignumber.js';
17
17
  import { pLimit } from 'plimit-lit';
18
18
  import { ExistingBalanceFunding, OnDemandFunding, X402Funding, } from '../types.js';
19
- import { isBlob, sleep } from '../utils/common.js';
19
+ import { isBlob, isValidArweaveBase64URL, sleep } from '../utils/common.js';
20
20
  import { AbortError } from '../utils/errors.js';
21
21
  import { FailedRequestError } from '../utils/errors.js';
22
+ import { contentHashFromFolderIndexKey, contentHashTagName, folderIndexKey, } from '../utils/folderIndex.js';
22
23
  import { ChunkedUploader } from './chunked.js';
23
24
  import { TurboEventEmitter, createStreamWithUploadEvents } from './events.js';
24
25
  import { defaultRetryConfig } from './http.js';
25
- import { TurboHTTPService } from './http.js';
26
+ import { TurboHTTPService, x402UploadEndpoints } from './http.js';
26
27
  import { exponentMap, tokenToBaseMap } from './index.js';
27
28
  import { Logger } from './logger.js';
28
29
  import { makeX402Signer } from './signer.js';
@@ -120,7 +121,9 @@ export class TurboUnauthenticatedUploadService {
120
121
  };
121
122
  return this.httpService.post({
122
123
  data: dataBuffer,
123
- endpoint: '/x402/data-item/unsigned',
124
+ // Only reached when no signer was supplied; the x402 path recomputes this
125
+ // from `unsignedData`. Both must name the same route.
126
+ endpoint: x402UploadEndpoints.unsigned,
124
127
  signal,
125
128
  headers: tags !== undefined
126
129
  ? { 'x-data-item-tags': JSON.stringify(tags) }
@@ -138,7 +141,6 @@ export class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUpl
138
141
  'solana',
139
142
  'base-eth',
140
143
  'base-usdc',
141
- 'base-ario',
142
144
  ];
143
145
  this.signer = signer;
144
146
  this.paymentService = paymentService;
@@ -220,9 +222,6 @@ export class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUpl
220
222
  !this.x402EnabledTokens.includes(this.token)) {
221
223
  throw new Error('x402 uploads are not supported for token: ' + this.token);
222
224
  }
223
- if (params.chunkingMode === 'force' && fundingMode instanceof X402Funding) {
224
- throw new Error("Chunking mode 'force' is not supported when x402 is enabled");
225
- }
226
225
  this.logger.debug('Starting file upload', { params });
227
226
  let retries = 0;
228
227
  const maxRetries = this.retryConfig.retries ?? 3;
@@ -258,6 +257,19 @@ export class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUpl
258
257
  // this result due to the wrapped retry logic of this method.
259
258
  try {
260
259
  const { chunkByteCount, maxChunkConcurrency } = params;
260
+ // Built here rather than after the chunked branch so BOTH paths can
261
+ // pay with it. Chunked x402 uploads used to be impossible — the
262
+ // bundler had no way to charge for a multipart upload — so this branch
263
+ // deliberately fell back to a single request, which capped x402 at the
264
+ // single-item limit no matter how the client chunked. The bundler now
265
+ // settles at create, so the fallback is no longer needed.
266
+ const x402Options = fundingMode instanceof X402Funding
267
+ ? {
268
+ signer: fundingMode.signer ??
269
+ (await makeX402Signer(this.signer.signer)),
270
+ maxMUSDCAmount: fundingMode.maxMUSDCAmount,
271
+ }
272
+ : undefined;
261
273
  const chunkedUploader = new ChunkedUploader({
262
274
  http: this.httpService,
263
275
  token: this.token,
@@ -267,9 +279,17 @@ export class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUpl
267
279
  dataItemByteCount: dataItemSizeFactory(),
268
280
  chunkingMode: params.chunkingMode,
269
281
  maxFinalizeMs: params.maxFinalizeMs,
282
+ ...(x402Options ? { x402: x402Options } : {}),
283
+ ...(x402Options
284
+ ? {
285
+ x402RefundIdentity: {
286
+ address: await this.signer.getNativeAddress(),
287
+ signatureType: this.signer.signer.signatureType,
288
+ },
289
+ }
290
+ : {}),
270
291
  });
271
- if (chunkedUploader.shouldUseChunkUploader &&
272
- !(fundingMode instanceof X402Funding)) {
292
+ if (chunkedUploader.shouldUseChunkUploader) {
273
293
  const response = await chunkedUploader.upload({
274
294
  dataItemStreamFactory,
275
295
  dataItemSizeFactory,
@@ -279,13 +299,6 @@ export class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUpl
279
299
  });
280
300
  return { ...response, cryptoFundResult };
281
301
  }
282
- const x402Options = fundingMode instanceof X402Funding
283
- ? {
284
- signer: fundingMode.signer ??
285
- (await makeX402Signer(this.signer.signer)),
286
- maxMUSDCAmount: fundingMode.maxMUSDCAmount,
287
- }
288
- : undefined;
289
302
  const response = await this.uploadSignedDataItem({
290
303
  dataItemStreamFactory,
291
304
  dataItemSizeFactory,
@@ -354,6 +367,214 @@ export class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUpl
354
367
  };
355
368
  return manifest;
356
369
  }
370
+ /**
371
+ * The sha-256 of a file's bytes, as lowercase hex. The key of a
372
+ * {@link TurboFolderUploadIndex}.
373
+ *
374
+ * This default consumes the file's stream and digests it with the platform
375
+ * WebCrypto implementation, which serves the browser. NodeJS overrides it
376
+ * with a streaming digest so a large file is never held in memory.
377
+ */
378
+ async computeContentHash(file) {
379
+ const stream = this.getFileStreamForFile(file);
380
+ const chunks = [];
381
+ if ('getReader' in stream) {
382
+ const reader = stream.getReader();
383
+ for (;;) {
384
+ const { done, value } = await reader.read();
385
+ if (done)
386
+ break;
387
+ chunks.push(value);
388
+ }
389
+ }
390
+ else {
391
+ for await (const chunk of stream) {
392
+ chunks.push(new Uint8Array(chunk));
393
+ }
394
+ }
395
+ const digest = await crypto.subtle.digest('SHA-256', Buffer.concat(chunks));
396
+ return Buffer.from(digest).toString('hex');
397
+ }
398
+ /**
399
+ * The exact tag set a folder upload writes for one file. Shared by the
400
+ * planner and the uploader, so that the tags a folder index key is computed
401
+ * from are, without question, the tags the data item ends up carrying.
402
+ */
403
+ folderFileTags({ file, dataItemOpts, contentHash, hashTagName = contentHashTagName, }) {
404
+ return [
405
+ ...(dataItemOpts?.tags?.filter((tag) => tag.name !== 'Content-Type' &&
406
+ // Only stripped when this upload is going to write its own. Without a
407
+ // folder index, a caller's File-SHA256 tag is theirs to keep.
408
+ (contentHash === undefined || tag.name !== hashTagName)) ?? []),
409
+ {
410
+ name: 'Content-Type',
411
+ value: this.getContentType(file, dataItemOpts),
412
+ },
413
+ ...(contentHash !== undefined
414
+ ? [{ name: hashTagName, value: contentHash }]
415
+ : []),
416
+ ];
417
+ }
418
+ /**
419
+ * A folder index is a cache, so a layer that is unreachable must mean a miss
420
+ * and not an aborted deploy. The write side is forgiving for the same reason.
421
+ */
422
+ async readFolderIndex(folderIndex, keys, signal) {
423
+ const knownIds = new Map();
424
+ const unresolved = [];
425
+ for (const key of keys) {
426
+ let id;
427
+ try {
428
+ id = await folderIndex.get(key);
429
+ }
430
+ catch (error) {
431
+ this.logger.error('Failed to read from folder index', error);
432
+ }
433
+ if (id !== undefined && isValidArweaveBase64URL(id)) {
434
+ knownIds.set(key, id);
435
+ }
436
+ else {
437
+ unresolved.push(key);
438
+ }
439
+ }
440
+ if (unresolved.length > 0 && folderIndex.resolve !== undefined) {
441
+ try {
442
+ const resolved = await folderIndex.resolve(unresolved, { signal });
443
+ for (const [key, id] of Object.entries(resolved)) {
444
+ if (isValidArweaveBase64URL(id)) {
445
+ knownIds.set(key, id);
446
+ }
447
+ }
448
+ }
449
+ catch (error) {
450
+ this.logger.error('Failed to resolve from folder index', error);
451
+ }
452
+ }
453
+ return knownIds;
454
+ }
455
+ /**
456
+ * Hashes every file and derives its folder index key, then works out which of
457
+ * those keys already have a data item id -- from the index, or from an
458
+ * identical file earlier in this same folder.
459
+ */
460
+ async planFolderIndex({ files, folderIndex, dataItemOpts, limit, signal, }) {
461
+ const contentHashes = new Map();
462
+ const itemKeys = new Map();
463
+ await Promise.all(files.map((file) => limit(async () => {
464
+ let contentHash;
465
+ try {
466
+ contentHash = await this.computeContentHash(file);
467
+ }
468
+ catch (error) {
469
+ // A file that cannot be read is left un-keyed and falls through to
470
+ // the per-file upload path, which reports it through `errors` under
471
+ // `throwOnFailure`. Rejecting here instead would abort the whole
472
+ // folder before any upload starts, so passing a folder index would
473
+ // silently change the failure contract.
474
+ this.logger.error('Failed to hash file for the folder index', {
475
+ file: this.getFileName(file),
476
+ error,
477
+ });
478
+ return;
479
+ }
480
+ contentHashes.set(file, contentHash);
481
+ itemKeys.set(file, await folderIndexKey({
482
+ contentHash,
483
+ tags: this.folderFileTags({
484
+ file,
485
+ dataItemOpts,
486
+ contentHash,
487
+ hashTagName: folderIndex.hashTagName,
488
+ }),
489
+ }));
490
+ })));
491
+ const knownIds = await this.readFolderIndex(folderIndex, [...new Set(itemKeys.values())], signal);
492
+ // Two files that would produce the same data item share a single upload, so
493
+ // the plan deduplicates within the folder as well as against the index.
494
+ const claimed = new Set();
495
+ const filesToUpload = files.filter((file) => {
496
+ const key = itemKeys.get(file);
497
+ // An un-keyed file could not be hashed. It is never a reuse candidate and
498
+ // must not share the dedup slot with the next un-keyed file, so it skips
499
+ // the claimed set entirely and always uploads.
500
+ if (key === undefined) {
501
+ return true;
502
+ }
503
+ if (knownIds.has(key) || claimed.has(key)) {
504
+ return false;
505
+ }
506
+ claimed.add(key);
507
+ return true;
508
+ });
509
+ this.logger.debug('Planned folder index', {
510
+ folderIndex: folderIndex.name,
511
+ totalFiles: files.length,
512
+ filesToUpload: filesToUpload.length,
513
+ });
514
+ await this.warnOnStaleTagMisses({ folderIndex, itemKeys, knownIds });
515
+ return { contentHashes, itemKeys, knownIds, filesToUpload };
516
+ }
517
+ /**
518
+ * A key covers the tags on a file as well as its bytes, so a tag in
519
+ * `dataItemOpts` that changes between deploys re-uploads the whole folder at
520
+ * full price. That is the right answer -- a reused data item is never one
521
+ * this call would not have made -- but on its own it is a silent cost cliff:
522
+ * a successful deploy, a full bill, and nothing saying why.
523
+ *
524
+ * The signature of that mistake is exact, and it is already in hand. A key is
525
+ * `<bytes>.<tags>`, so a file whose *bytes half* the index knows under some
526
+ * other tags half is a file whose content is already paid for and whose tags
527
+ * moved. Nothing else produces that: a folder the index has never seen has
528
+ * unknown bytes, and a layer that could not be reached reports nothing known.
529
+ * It also catches one file in a hundred, not just all of them.
530
+ */
531
+ async warnOnStaleTagMisses({ folderIndex, itemKeys, knownIds, }) {
532
+ if (folderIndex.knownContentHashes === undefined) {
533
+ return;
534
+ }
535
+ // Counted in files, not in keys: the message is about a bill, and the bill
536
+ // is per file. Two files that share a key are one upload but the reader is
537
+ // looking for their own file count.
538
+ const filesPerKey = new Map();
539
+ for (const key of itemKeys.values()) {
540
+ filesPerKey.set(key, (filesPerKey.get(key) ?? 0) + 1);
541
+ }
542
+ const missed = [...filesPerKey.keys()].filter((key) => !knownIds.has(key));
543
+ if (missed.length === 0) {
544
+ return;
545
+ }
546
+ const filesFor = (keys) => keys.reduce((count, key) => count + (filesPerKey.get(key) ?? 0), 0);
547
+ const missedFiles = filesFor(missed);
548
+ const missedByContentHash = new Map();
549
+ for (const key of missed) {
550
+ const contentHash = contentHashFromFolderIndexKey(key);
551
+ missedByContentHash.set(contentHash, [
552
+ ...(missedByContentHash.get(contentHash) ?? []),
553
+ key,
554
+ ]);
555
+ }
556
+ let staleTagged = [];
557
+ try {
558
+ staleTagged =
559
+ (await folderIndex.knownContentHashes([
560
+ ...missedByContentHash.keys(),
561
+ ])) ?? [];
562
+ }
563
+ catch (error) {
564
+ this.logger.error('Failed to read from folder index', error);
565
+ return;
566
+ }
567
+ const affected = staleTagged.reduce((count, contentHash) => count + filesFor(missedByContentHash.get(contentHash) ?? []), 0);
568
+ if (affected === 0) {
569
+ return;
570
+ }
571
+ this.logger.warn(`${affected} of the ${missedFiles} file(s) this run is about to upload are already on Arweave byte for byte, under a different set of tags. ` +
572
+ 'Their content has not changed but their tags have, so they are being paid for again. A folder index key covers the tags ' +
573
+ 'on a file as well as its bytes. That is usually a tag in dataItemOpts whose value changes between deploys -- a commit ' +
574
+ 'sha, a build number, a timestamp -- in which case move it to manifestDataItemOpts rather than paying for these files ' +
575
+ 'again. It can also be a file that kept its content but changed its Content-Type, through a rename or a new extension, ' +
576
+ 'which is expected and costs one upload.');
577
+ }
357
578
  getContentType(file, dataItemOpts) {
358
579
  const userDefinedContentType = dataItemOpts?.tags?.find((tag) => tag.name === 'Content-Type')?.value;
359
580
  if (userDefinedContentType !== undefined) {
@@ -363,7 +584,7 @@ export class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUpl
363
584
  }
364
585
  async uploadFolder(params) {
365
586
  this.logger.debug('Uploading folder...', { params });
366
- const { dataItemOpts, signal, manifestOptions = {}, maxConcurrentUploads = 1, throwOnFailure = true, maxChunkConcurrency, chunkByteCount, chunkingMode, fundingMode = new ExistingBalanceFunding(), maxFinalizeMs, events = {}, } = params;
587
+ const { dataItemOpts, manifestDataItemOpts = dataItemOpts, folderIndex, signal, manifestOptions = {}, maxConcurrentUploads = 1, throwOnFailure = true, maxChunkConcurrency, chunkByteCount, chunkingMode, fundingMode = new ExistingBalanceFunding(), maxFinalizeMs, events = {}, } = params;
367
588
  const { disableManifest, indexFile, fallbackFile } = manifestOptions;
368
589
  // Create event emitter from events parameter
369
590
  const emitter = new TurboEventEmitter(events);
@@ -372,15 +593,37 @@ export class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUpl
372
593
  fileResponses: [],
373
594
  };
374
595
  const errors = [];
596
+ const limit = pLimit(maxConcurrentUploads);
375
597
  // Get files and calculate total bytes upfront for progress tracking
376
598
  const files = await this.getFiles(params);
377
- const totalFiles = files.length;
599
+ // With an index, only the files whose bytes are not already on Arweave are
600
+ // signed, uploaded and paid for. Progress totals cover that subset, since
601
+ // it is all this call actually does.
602
+ let contentHashes = new Map();
603
+ let itemKeys = new Map();
604
+ let knownIds = new Map();
605
+ let filesToUpload = files;
606
+ if (folderIndex !== undefined) {
607
+ ({ contentHashes, itemKeys, knownIds, filesToUpload } =
608
+ await this.planFolderIndex({
609
+ files,
610
+ folderIndex,
611
+ dataItemOpts,
612
+ limit,
613
+ signal,
614
+ }));
615
+ }
616
+ const totalFiles = filesToUpload.length;
378
617
  let totalBytes = 0;
618
+ let folderBytes = 0;
379
619
  const fileSizes = new Map();
380
620
  files.forEach((file) => {
381
621
  const size = this.getFileSize(file);
382
622
  fileSizes.set(file, size);
383
- totalBytes += size;
623
+ folderBytes += size;
624
+ });
625
+ filesToUpload.forEach((file) => {
626
+ totalBytes += fileSizes.get(file) ?? 0;
384
627
  });
385
628
  // Track progress across all files
386
629
  let processedFiles = 0;
@@ -395,13 +638,15 @@ export class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUpl
395
638
  fileIndex,
396
639
  totalFiles,
397
640
  });
398
- const contentType = this.getContentType(file, dataItemOpts);
641
+ const contentHash = contentHashes.get(file);
399
642
  const dataItemOptsWithContentType = {
400
643
  ...dataItemOpts,
401
- tags: [
402
- ...(dataItemOpts?.tags?.filter((tag) => tag.name !== 'Content-Type') ?? []),
403
- { name: 'Content-Type', value: contentType },
404
- ],
644
+ tags: this.folderFileTags({
645
+ file,
646
+ dataItemOpts,
647
+ contentHash,
648
+ hashTagName: folderIndex?.hashTagName,
649
+ }),
405
650
  };
406
651
  try {
407
652
  const result = await this.uploadFile({
@@ -446,8 +691,24 @@ export class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUpl
446
691
  },
447
692
  fundingMode,
448
693
  });
449
- const relativePath = this.getRelativePath(file, params);
450
- paths[relativePath] = { id: result.id };
694
+ const itemKey = itemKeys.get(file);
695
+ if (itemKey !== undefined) {
696
+ // Recorded before anything else can fail, so a run that is killed
697
+ // part way through never loses a file it has already paid for.
698
+ knownIds.set(itemKey, result.id);
699
+ try {
700
+ await folderIndex?.set(itemKey, result.id);
701
+ }
702
+ catch (error) {
703
+ // A failed index write costs the next run a re-upload; it must not
704
+ // fail this one, which has already paid for and landed the bytes.
705
+ this.logger.error('Failed to write to folder index', error);
706
+ }
707
+ }
708
+ else {
709
+ const relativePath = this.getRelativePath(file, params);
710
+ paths[relativePath] = { id: result.id };
711
+ }
451
712
  response.fileResponses.push(result);
452
713
  // Update processed counts after file completes
453
714
  processedFiles++;
@@ -483,10 +744,9 @@ export class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUpl
483
744
  errors.push(error);
484
745
  }
485
746
  };
486
- const limit = pLimit(maxConcurrentUploads);
487
747
  let cryptoFundResult;
488
748
  if (fundingMode instanceof OnDemandFunding) {
489
- const totalByteCount = files.reduce((acc, file) => {
749
+ const totalByteCount = filesToUpload.reduce((acc, file) => {
490
750
  return acc + this.getFileSize(file) + 1200; // allow extra per file for ANS-104 headers
491
751
  }, 0);
492
752
  cryptoFundResult = await this.onDemand({
@@ -494,15 +754,36 @@ export class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUpl
494
754
  onDemandFunding: fundingMode,
495
755
  });
496
756
  }
497
- await Promise.all(files.map((file, index) => limit(() => uploadFile(file, index))));
757
+ await Promise.all(filesToUpload.map((file, index) => limit(() => uploadFile(file, index))));
498
758
  this.logger.debug('Finished uploading files', {
499
- numFiles: files.length,
759
+ numFiles: filesToUpload.length,
500
760
  numErrors: errors.length,
501
761
  results: response.fileResponses,
502
762
  });
503
763
  if (errors.length > 0) {
504
764
  response.errors = errors;
505
765
  }
766
+ if (folderIndex !== undefined) {
767
+ // Built in folder order rather than in completion order, so an unchanged
768
+ // folder produces byte identical manifest bytes -- and therefore an
769
+ // identical manifest data item id -- run after run.
770
+ for (const file of files) {
771
+ const id = knownIds.get(itemKeys.get(file));
772
+ if (id !== undefined) {
773
+ paths[this.getRelativePath(file, params)] = { id };
774
+ }
775
+ }
776
+ response.folderIndexSummary = {
777
+ totalFiles: files.length,
778
+ totalBytes: folderBytes,
779
+ // What landed, not what was planned. With throwOnFailure: false a file
780
+ // can be planned, paid for nothing, and never arrive.
781
+ uploadedFiles: processedFiles,
782
+ uploadedBytes: processedBytes,
783
+ reusedFiles: files.length - filesToUpload.length,
784
+ reusedBytes: folderBytes - totalBytes,
785
+ };
786
+ }
506
787
  if (disableManifest) {
507
788
  emitter.emit('folder-success');
508
789
  return response;
@@ -521,8 +802,7 @@ export class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUpl
521
802
  fallbackFile,
522
803
  });
523
804
  const tagsWithManifestContentType = [
524
- ...(dataItemOpts?.tags?.filter((tag) => tag.name !== 'Content-Type') ??
525
- []),
805
+ ...(manifestDataItemOpts?.tags?.filter((tag) => tag.name !== 'Content-Type') ?? []),
526
806
  { name: 'Content-Type', value: 'application/x.arweave-manifest+json' },
527
807
  ];
528
808
  const manifestBuffer = Buffer.from(JSON.stringify(manifest));
@@ -532,7 +812,10 @@ export class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUpl
532
812
  fileStreamFactory: () => this.createManifestStream(manifestBuffer),
533
813
  fileSizeFactory: () => manifestBuffer.byteLength,
534
814
  signal,
535
- dataItemOpts: { ...dataItemOpts, tags: tagsWithManifestContentType },
815
+ dataItemOpts: {
816
+ ...manifestDataItemOpts,
817
+ tags: tagsWithManifestContentType,
818
+ },
536
819
  chunkByteCount,
537
820
  maxChunkConcurrency,
538
821
  maxFinalizeMs,
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Copyright (C) 2022-2024 Permanent Data Solutions, Inc.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
17
+ import { dirname, resolve as resolvePath } from 'node:path';
18
+ import { isValidArweaveBase64URL } from '../utils/common.js';
19
+ import { contentHashFromFolderIndexKey, isValidFolderIndexKey, } from '../utils/folderIndex.js';
20
+ const fileFolderIndexVersion = 2;
21
+ /**
22
+ * A local record of folder index key to data item id, for uploads on a machine
23
+ * that keeps its working directory between deploys.
24
+ *
25
+ * The file is an append-only log, one JSON record per line, compacted when it
26
+ * is loaded. That shape falls out of two requirements pulling against each
27
+ * other:
28
+ *
29
+ * - It has to be written after every single upload, not once at the end of the
30
+ * run. An upload that has been paid for but forgotten is money burnt, and a
31
+ * deploy killed part way through is the normal case rather than the
32
+ * exceptional one.
33
+ * - Rewriting the whole file per upload is quadratic. A first deploy or a
34
+ * migration of a few thousand files spends minutes and gigabytes of writes on
35
+ * nothing but bookkeeping.
36
+ *
37
+ * Appending one line is constant work, and it is *more* crash safe than a
38
+ * rewrite rather than less: a process killed mid write can only damage the last
39
+ * line, which is dropped on load, where a torn rewrite loses every id in the
40
+ * file. Compaction happens once per load, through a temp file and a rename, so
41
+ * the readable file is never the partial one.
42
+ *
43
+ * Malformed records are dropped. A hand edited index must never be able to
44
+ * publish a manifest pointing at nothing.
45
+ */
46
+ export function createFileFolderIndex({ filePath, logger, }) {
47
+ const absolutePath = resolvePath(filePath);
48
+ const tempPath = `${absolutePath}.tmp`;
49
+ const map = new Map();
50
+ const record = (key, id) => `${JSON.stringify({ k: key, i: id })}\n`;
51
+ const header = `${JSON.stringify({ folderIndex: fileFolderIndexVersion })}\n`;
52
+ /** Rewrites the log as one record per live entry, atomically. */
53
+ const compact = () => {
54
+ mkdirSync(dirname(absolutePath), { recursive: true });
55
+ const body = [...map]
56
+ // Code units, not `localeCompare`, for the same reason the key encoding
57
+ // avoids it: it is not a total order over distinct strings and depends on
58
+ // the host's ICU data. Only the file's readability rides on this one, but
59
+ // a sort that can call two distinct keys equal has no business here.
60
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
61
+ .map(([key, id]) => record(key, id))
62
+ .join('');
63
+ try {
64
+ writeFileSync(tempPath, `${header}${body}`);
65
+ renameSync(tempPath, absolutePath);
66
+ }
67
+ catch (error) {
68
+ rmSync(tempPath, { force: true });
69
+ throw error;
70
+ }
71
+ };
72
+ let lineCount = 0;
73
+ if (existsSync(absolutePath)) {
74
+ let malformed = 0;
75
+ // A line at a time, so a truncated tail costs one entry rather than all of
76
+ // them.
77
+ for (const line of readFileSync(absolutePath, 'utf-8').split('\n')) {
78
+ if (line.length === 0) {
79
+ continue;
80
+ }
81
+ lineCount++;
82
+ try {
83
+ const parsed = JSON.parse(line);
84
+ if (parsed?.folderIndex !== undefined) {
85
+ continue; // header
86
+ }
87
+ if (isValidFolderIndexKey(parsed?.k) &&
88
+ isValidArweaveBase64URL(parsed?.i)) {
89
+ map.set(parsed.k, parsed.i); // last write wins
90
+ }
91
+ else {
92
+ malformed++;
93
+ }
94
+ }
95
+ catch {
96
+ malformed++;
97
+ }
98
+ }
99
+ if (malformed > 0) {
100
+ logger?.warn(`Dropped ${malformed} unreadable record(s) from the folder index at ${absolutePath}`);
101
+ }
102
+ // Only worth rewriting when the log has actually accumulated slack.
103
+ if (lineCount > map.size + 1) {
104
+ try {
105
+ compact();
106
+ }
107
+ catch (error) {
108
+ logger?.warn(`Could not compact the folder index at ${absolutePath}`, error);
109
+ }
110
+ }
111
+ }
112
+ return {
113
+ name: `file:${absolutePath}`,
114
+ get: (key) => map.get(key),
115
+ set: (key, id) => {
116
+ map.set(key, id);
117
+ mkdirSync(dirname(absolutePath), { recursive: true });
118
+ if (!existsSync(absolutePath)) {
119
+ writeFileSync(absolutePath, header);
120
+ }
121
+ appendFileSync(absolutePath, record(key, id));
122
+ },
123
+ knownContentHashes: (contentHashes) => {
124
+ const known = new Set([...map.keys()].map(contentHashFromFolderIndexKey));
125
+ return contentHashes.filter((contentHash) => known.has(contentHash));
126
+ },
127
+ entries: () => Object.fromEntries(map),
128
+ };
129
+ }
@@ -14,6 +14,7 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  export * from './factory.js';
17
+ export * from './folderIndex.js';
17
18
  export * from './signer.js';
18
19
  export * from './upload.js';
19
20
  export * from '../types.js';
@@ -15,6 +15,8 @@
15
15
  */
16
16
  import { createReadStream, promises, statSync } from 'fs';
17
17
  import { lookup } from 'mime-types';
18
+ import { createHash } from 'node:crypto';
19
+ import { pipeline } from 'node:stream/promises';
18
20
  import { join } from 'path';
19
21
  import { Readable } from 'stream';
20
22
  import { TurboAuthenticatedBaseUploadService, defaultUploadServiceURL, } from '../common/upload.js';
@@ -84,4 +86,9 @@ export class TurboAuthenticatedUploadService extends TurboAuthenticatedBaseUploa
84
86
  createManifestStream(manifestBuffer) {
85
87
  return Readable.from(manifestBuffer);
86
88
  }
89
+ async computeContentHash(file) {
90
+ const hash = createHash('sha256');
91
+ await pipeline(createReadStream(file), hash);
92
+ return hash.digest('hex');
93
+ }
87
94
  }