@beam-network/sdk 0.2.10 → 0.2.12

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/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AttachSignedUrlsResponse, BeamClientOptions, DistributeResponse, PreparedDestination, PreparedHttpSource, ProviderTransferCreateInput, RawTransferCreateInput, SignedChunkRoute, SignedUrlFlow, TransferCancelResponse, TransferCreateResponse, TransferPrepareResponse, TransferStatusInfo } from "./models.js";
1
+ import type { AttachSignedUrlsResponse, BeamClientOptions, DistributeResponse, PlanningHttpSource, PreparedDestination, PreparedHttpSource, ProviderTransferCreateInput, RawTransferCreateInput, SignedChunkRoute, SignedUrlFlow, TransferCancelResponse, TransferCreateResponse, TransferPlanResponse, TransferPrepareResponse, TransferStatusInfo } from "./models.js";
2
2
  export declare const BEAM_DEFAULT_URL = "https://beamcore.b1m.ai";
3
3
  export declare class BeamApiError extends Error {
4
4
  readonly status: number;
@@ -15,11 +15,21 @@ export declare class BeamClient {
15
15
  transferStatus(transferId: string): Promise<TransferStatusInfo>;
16
16
  distributeTransfer(transferId: string): Promise<DistributeResponse>;
17
17
  cancelTransfer(transferId: string): Promise<TransferCancelResponse>;
18
+ planTransfer(input: {
19
+ sources: PlanningHttpSource[];
20
+ destinations: PreparedDestination[];
21
+ name?: string;
22
+ testMode?: boolean;
23
+ experimental?: boolean;
24
+ urlsExpiresAt?: string;
25
+ signedUrlFlow?: SignedUrlFlow;
26
+ }): Promise<TransferPlanResponse>;
18
27
  prepareTransfer(input: {
19
28
  sources: PreparedHttpSource[];
20
29
  destinations: PreparedDestination[];
21
30
  name?: string;
22
31
  testMode?: boolean;
32
+ experimental?: boolean;
23
33
  urlsExpiresAt?: string;
24
34
  signedUrlFlow?: SignedUrlFlow;
25
35
  }): Promise<TransferPrepareResponse>;
package/dist/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { abortMultipartUpload, computeChunkContentMd5, createMultipartUpload, nowIso, prepareProviderDestination, prepareProviderSource, isS3CompatibleProvider, listPartsPageMarker, listPartsVerifyMarker, signAbortMultipartUpload, signCompleteMultipartUpload, signDeleteObject, signDestinationRoute, signListMultipartUpload, signUploadPartCopy } from "./provider-signing.js";
1
+ import { abortMultipartUpload, computeChunksContentMd5, createMultipartUpload, nowIso, prepareProviderDestination, prepareProviderSource, prepareProviderSourceForPlan, isS3CompatibleProvider, listPartsPageMarker, listPartsVerifyMarker, signAbortMultipartUpload, signCompleteMultipartUpload, signDeleteObject, signDestinationRoute, signListMultipartUpload, signUploadPartCopy } from "./provider-signing.js";
2
2
  export const BEAM_DEFAULT_URL = "https://beamcore.b1m.ai";
3
3
  export class BeamApiError extends Error {
4
4
  status;
@@ -36,6 +36,7 @@ export class BeamClient {
36
36
  chunk_hashes: input.chunkHashes,
37
37
  callbacks: input.callbacks,
38
38
  test_mode: input.testMode || undefined,
39
+ experimental: input.experimental || undefined,
39
40
  progressive_mode: input.progressiveMode || undefined,
40
41
  signed_url_flow: input.signedUrlFlow
41
42
  });
@@ -58,12 +59,24 @@ export class BeamClient {
58
59
  validateId(transferId, "transferId");
59
60
  return this.request("DELETE", `/transfers/${encodeURIComponent(transferId)}`);
60
61
  }
62
+ planTransfer(input) {
63
+ return this.request("POST", "/transfers/plan", compact({
64
+ sources: input.sources,
65
+ destinations: input.destinations,
66
+ name: input.name,
67
+ test_mode: input.testMode || undefined,
68
+ experimental: input.experimental || undefined,
69
+ urls_expires_at: input.urlsExpiresAt,
70
+ signed_url_flow: input.signedUrlFlow
71
+ }));
72
+ }
61
73
  prepareTransfer(input) {
62
74
  return this.request("POST", "/transfers/prepare", compact({
63
75
  sources: input.sources,
64
76
  destinations: input.destinations,
65
77
  name: input.name,
66
78
  test_mode: input.testMode || undefined,
79
+ experimental: input.experimental || undefined,
67
80
  urls_expires_at: input.urlsExpiresAt,
68
81
  signed_url_flow: input.signedUrlFlow
69
82
  }));
@@ -79,45 +92,119 @@ export class BeamClient {
79
92
  }
80
93
  async prepareProviderTransfer(input) {
81
94
  const expiresIn = input.expiresIn ?? 3600;
82
- const preparedSources = await Promise.all(input.sources.map((source, index) => prepareProviderSource(source, { index, expiresIn, fetchImpl: this.fetchImpl })));
95
+ const requestedSignedUrlFlow = input.signedUrlFlow ?? "signed_url_v1";
83
96
  const preparedDestinations = input.destinations.map((destination, index) => prepareProviderDestination(destination, { index }));
84
- const prepared = await this.prepareTransfer({
85
- sources: preparedSources,
86
- destinations: preparedDestinations,
87
- name: input.name,
88
- testMode: input.testMode,
89
- signedUrlFlow: input.signedUrlFlow
97
+ const destinationsById = new Map();
98
+ preparedDestinations.forEach((preparedDestination, index) => {
99
+ const destination = input.destinations[index];
100
+ if (!destination) {
101
+ throw new Error(`missing provider destination config for prepared destination ${preparedDestination.destination_id}`);
102
+ }
103
+ destinationsById.set(preparedDestination.destination_id, destination);
104
+ });
105
+ const checksumCache = new ChunkChecksumCache({
106
+ concurrency: input.checksumReadConcurrency ?? 8,
107
+ expiresIn,
108
+ fetchImpl: this.fetchImpl,
109
+ provider: input.checksumProvider,
110
+ groupSizeBytes: input.checksumGroupSizeBytes ?? 256 * 1024 * 1024,
111
+ onEvent: input.onChecksumEvent,
112
+ throwIfCancelled: () => input.throwIfCancelled?.()
90
113
  });
91
- if (!prepared.success) {
92
- return prepared;
114
+ let preparedSources;
115
+ let prepared;
116
+ if (requestedSignedUrlFlow === "signed_url_v1") {
117
+ await input.throwIfCancelled?.();
118
+ const plannedSources = await Promise.all(input.sources.map((source, index) => prepareProviderSourceForPlan(source, { index, fetchImpl: this.fetchImpl })));
119
+ const planned = await this.planTransfer({
120
+ sources: plannedSources,
121
+ destinations: preparedDestinations,
122
+ name: input.name,
123
+ testMode: input.testMode,
124
+ experimental: input.experimental,
125
+ signedUrlFlow: requestedSignedUrlFlow
126
+ });
127
+ if (!planned.success) {
128
+ return failedPlanAsPrepareResponse(planned);
129
+ }
130
+ await input.onPlanned?.(planned);
131
+ await input.throwIfCancelled?.();
132
+ const plannedSourcesById = mapSourcesByPreparedId(plannedSources, input.sources);
133
+ const chunksBySource = checksumChunksBySource(planned.chunk_plan, destinationsById);
134
+ const totalWarmupChunks = [...chunksBySource.values()].reduce((sum, chunks) => sum + chunks.length, 0);
135
+ const totalWarmupBytes = [...chunksBySource.values()].reduce((sum, chunks) => sum + chunks.reduce((chunkSum, chunk) => chunkSum + chunk.chunk_size, 0), 0);
136
+ if (totalWarmupChunks > 0) {
137
+ const startedAt = Date.now();
138
+ emitChecksumEvent(input, {
139
+ event: "checksum_warmup_started",
140
+ sourceId: "all",
141
+ chunkCount: totalWarmupChunks,
142
+ bytes: totalWarmupBytes
143
+ });
144
+ for (const [sourceId, chunks] of chunksBySource) {
145
+ await input.throwIfCancelled?.();
146
+ const source = plannedSourcesById.get(sourceId);
147
+ if (!source) {
148
+ throw new Error(`BeamCore planned unknown source_id: ${sourceId}`);
149
+ }
150
+ await checksumCache.ensureMany(source, chunks);
151
+ }
152
+ emitChecksumEvent(input, {
153
+ event: "checksum_warmup_completed",
154
+ sourceId: "all",
155
+ chunkCount: totalWarmupChunks,
156
+ bytes: totalWarmupBytes,
157
+ durationMs: Date.now() - startedAt
158
+ });
159
+ }
160
+ await input.throwIfCancelled?.();
161
+ preparedSources = await Promise.all(input.sources.map((source, index) => prepareProviderSource(source, { index, expiresIn, fetchImpl: this.fetchImpl })));
162
+ await input.onBeforeTransferPrepare?.();
163
+ prepared = await this.prepareTransfer({
164
+ sources: preparedSources,
165
+ destinations: preparedDestinations,
166
+ name: input.name,
167
+ testMode: input.testMode,
168
+ experimental: input.experimental,
169
+ signedUrlFlow: requestedSignedUrlFlow
170
+ });
171
+ if (!prepared.success) {
172
+ return prepared;
173
+ }
174
+ assertPreparedTransferMatchesPlan(planned, prepared);
175
+ }
176
+ else {
177
+ preparedSources = await Promise.all(input.sources.map((source, index) => prepareProviderSource(source, { index, expiresIn, fetchImpl: this.fetchImpl })));
178
+ await input.onBeforeTransferPrepare?.();
179
+ prepared = await this.prepareTransfer({
180
+ sources: preparedSources,
181
+ destinations: preparedDestinations,
182
+ name: input.name,
183
+ testMode: input.testMode,
184
+ experimental: input.experimental,
185
+ signedUrlFlow: requestedSignedUrlFlow
186
+ });
187
+ if (!prepared.success) {
188
+ return prepared;
189
+ }
93
190
  }
94
191
  await input.onPrepared?.(prepared);
95
192
  await input.throwIfCancelled?.(prepared.transfer_id);
96
- const destinationsById = new Map();
97
- preparedDestinations.forEach((preparedDestination, index) => {
98
- destinationsById.set(preparedDestination.destination_id, input.destinations[index]);
99
- });
100
193
  const sourcesById = new Map();
101
194
  preparedSources.forEach((preparedSource, index) => {
102
- sourcesById.set(preparedSource.source_id, input.sources[index]);
103
- });
104
- const signedUrlFlow = prepared.signed_url_flow ?? input.signedUrlFlow ?? "signed_url_v1";
105
- const checksumCache = new ChunkChecksumCache({
106
- concurrency: 4,
107
- expiresIn,
108
- fetchImpl: this.fetchImpl
195
+ const source = input.sources[index];
196
+ if (!source) {
197
+ throw new Error(`missing provider source config for prepared source ${preparedSource.source_id}`);
198
+ }
199
+ sourcesById.set(preparedSource.source_id, source);
109
200
  });
201
+ const signedUrlFlow = prepared.signed_url_flow ?? requestedSignedUrlFlow;
110
202
  if (signedUrlFlow === "signed_url_v1") {
111
- for (const chunk of prepared.chunk_plan) {
112
- const needsChecksum = chunk.destinations.some((target) => {
113
- const destination = destinationsById.get(target.destination_id);
114
- return destination ? isS3CompatibleProvider(destination) : false;
115
- });
116
- if (!needsChecksum)
117
- continue;
118
- const source = sourcesById.get(chunk.source_id);
203
+ const chunksBySource = checksumChunksBySource(prepared.chunk_plan, destinationsById);
204
+ for (const [sourceId, chunks] of chunksBySource) {
205
+ const source = sourcesById.get(sourceId);
119
206
  if (source)
120
- checksumCache.preload(source, chunk);
207
+ checksumCache.preloadMany(source, chunks);
121
208
  }
122
209
  }
123
210
  const routes = [];
@@ -342,6 +429,83 @@ export class BeamClient {
342
429
  return (text ? JSON.parse(text) : {});
343
430
  }
344
431
  }
432
+ function checksumChunksBySource(chunkPlan, destinationsById) {
433
+ const chunksBySource = new Map();
434
+ for (const chunk of chunkPlan) {
435
+ const needsChecksum = chunk.destinations.some((target) => {
436
+ const destination = destinationsById.get(target.destination_id);
437
+ return destination ? isS3CompatibleProvider(destination) : false;
438
+ });
439
+ if (!needsChecksum)
440
+ continue;
441
+ const chunks = chunksBySource.get(chunk.source_id) ?? [];
442
+ chunks.push(chunk);
443
+ chunksBySource.set(chunk.source_id, chunks);
444
+ }
445
+ return chunksBySource;
446
+ }
447
+ function mapSourcesByPreparedId(preparedSources, providerSources) {
448
+ const sourcesById = new Map();
449
+ preparedSources.forEach((preparedSource, index) => {
450
+ const source = providerSources[index];
451
+ if (!source) {
452
+ throw new Error(`missing provider source config for planned source ${preparedSource.source_id}`);
453
+ }
454
+ sourcesById.set(preparedSource.source_id, source);
455
+ });
456
+ return sourcesById;
457
+ }
458
+ function failedPlanAsPrepareResponse(plan) {
459
+ return {
460
+ success: false,
461
+ transfer_id: "",
462
+ chunk_size: plan.chunk_size,
463
+ total_size: plan.total_size,
464
+ total_sources: plan.total_sources,
465
+ total_destinations: plan.total_destinations,
466
+ logical_chunks: plan.logical_chunks,
467
+ total_chunks: plan.total_chunks,
468
+ chunk_plan: plan.chunk_plan ?? [],
469
+ signed_url_flow: plan.signed_url_flow,
470
+ error: plan.error,
471
+ message: plan.message
472
+ };
473
+ }
474
+ function assertPreparedTransferMatchesPlan(plan, prepared) {
475
+ const summaryMatches = plan.chunk_size === prepared.chunk_size &&
476
+ plan.total_size === prepared.total_size &&
477
+ plan.total_sources === prepared.total_sources &&
478
+ plan.total_destinations === prepared.total_destinations &&
479
+ plan.logical_chunks === prepared.logical_chunks &&
480
+ plan.total_chunks === prepared.total_chunks;
481
+ if (!summaryMatches || planSignature(plan.chunk_plan) !== planSignature(prepared.chunk_plan)) {
482
+ throw new Error("prepared transfer chunk plan does not match preflight checksum plan");
483
+ }
484
+ }
485
+ function planSignature(chunks) {
486
+ return JSON.stringify(chunks.map((chunk) => ({
487
+ chunk_index: chunk.chunk_index,
488
+ source_id: chunk.source_id,
489
+ source_chunk_index: chunk.source_chunk_index,
490
+ source_offset: chunk.source_offset,
491
+ chunk_size: chunk.chunk_size,
492
+ destinations: chunk.destinations.map((target) => {
493
+ const metadata = target.metadata ?? {};
494
+ return {
495
+ destination_id: target.destination_id,
496
+ provider: target.provider,
497
+ final_object_key: typeof metadata.final_object_key === "string" ? metadata.final_object_key : target.object_key,
498
+ part_number: typeof metadata.part_number === "number" ? metadata.part_number : undefined,
499
+ delivery_index: typeof metadata.delivery_index === "number" ? metadata.delivery_index : undefined
500
+ };
501
+ })
502
+ })));
503
+ }
504
+ function emitChecksumEvent(input, event) {
505
+ if (!input.onChecksumEvent)
506
+ return;
507
+ void Promise.resolve(input.onChecksumEvent(event)).catch(() => undefined);
508
+ }
345
509
  class ChunkChecksumCache {
346
510
  options;
347
511
  queue = [];
@@ -350,22 +514,128 @@ class ChunkChecksumCache {
350
514
  constructor(options) {
351
515
  this.options = options;
352
516
  }
353
- preload(source, chunk) {
354
- void this.get(source, chunk).catch(() => undefined);
517
+ preloadMany(source, chunks) {
518
+ const pending = this.uniqueChunks(chunks).filter((chunk) => !this.cache.has(this.key(chunk)));
519
+ for (const chunk of pending) {
520
+ this.cache.set(this.key(chunk), deferredChecksum());
521
+ }
522
+ for (const group of this.groupChunks(pending)) {
523
+ void this.run(() => this.fillGroup(source, group)).catch(() => undefined);
524
+ }
525
+ }
526
+ async ensureMany(source, chunks) {
527
+ const unique = this.uniqueChunks(chunks);
528
+ this.preloadMany(source, unique);
529
+ await Promise.all(unique.map((chunk) => this.get(source, chunk)));
355
530
  }
356
531
  get(source, chunk) {
357
- const key = `${chunk.source_id}:${chunk.source_chunk_index}`;
532
+ const key = this.key(chunk);
358
533
  let existing = this.cache.get(key);
359
534
  if (!existing) {
360
- existing = this.run(() => computeChunkContentMd5({
535
+ existing = deferredChecksum();
536
+ this.cache.set(key, existing);
537
+ void this.run(() => this.fillGroup(source, [chunk])).catch(() => undefined);
538
+ }
539
+ return existing.promise;
540
+ }
541
+ async fillGroup(source, chunks) {
542
+ const unresolved = new Set(chunks.map((chunk) => this.key(chunk)));
543
+ try {
544
+ await this.options.throwIfCancelled?.();
545
+ const misses = [];
546
+ for (const chunk of chunks) {
547
+ const key = this.key(chunk);
548
+ const deferred = this.cache.get(key);
549
+ if (!deferred)
550
+ continue;
551
+ const cached = await this.options.provider?.getContentMd5({ source, chunk, algorithm: "content-md5" }).catch(() => undefined);
552
+ if (cached) {
553
+ this.emit({ event: "checksum_cache_hit", sourceId: chunk.source_id, sourceChunkIndex: chunk.source_chunk_index, bytes: chunk.chunk_size });
554
+ deferred.resolve(cached);
555
+ unresolved.delete(key);
556
+ }
557
+ else {
558
+ this.emit({ event: "checksum_cache_miss", sourceId: chunk.source_id, sourceChunkIndex: chunk.source_chunk_index, bytes: chunk.chunk_size });
559
+ misses.push(chunk);
560
+ }
561
+ }
562
+ if (!misses.length)
563
+ return;
564
+ await this.options.throwIfCancelled?.();
565
+ const startedAt = Date.now();
566
+ const checksums = await computeChunksContentMd5({
361
567
  source,
362
- chunk,
568
+ chunks: misses,
363
569
  expiresIn: this.options.expiresIn,
364
- fetchImpl: this.options.fetchImpl
365
- }));
366
- this.cache.set(key, existing);
570
+ fetchImpl: this.options.fetchImpl,
571
+ groupSizeBytes: this.options.groupSizeBytes
572
+ });
573
+ for (const chunk of misses) {
574
+ await this.options.throwIfCancelled?.();
575
+ const checksum = checksums.get(chunk.source_chunk_index);
576
+ if (!checksum)
577
+ throw new Error(`checksum missing for source chunk ${chunk.source_chunk_index}`);
578
+ await this.options.provider?.setContentMd5?.({ source, chunk, algorithm: "content-md5", contentMd5: checksum });
579
+ const deferred = this.cache.get(this.key(chunk));
580
+ deferred?.resolve(checksum);
581
+ unresolved.delete(this.key(chunk));
582
+ this.emit({ event: "checksum_cold_chunk_cached", sourceId: chunk.source_id, sourceChunkIndex: chunk.source_chunk_index, bytes: chunk.chunk_size });
583
+ }
584
+ this.emit({
585
+ event: "checksum_cold_group_completed",
586
+ sourceId: misses[0]?.source_id ?? source.id ?? "source",
587
+ chunkCount: misses.length,
588
+ bytes: misses.reduce((sum, chunk) => sum + chunk.chunk_size, 0),
589
+ durationMs: Date.now() - startedAt
590
+ });
591
+ }
592
+ catch (error) {
593
+ for (const key of unresolved) {
594
+ this.cache.get(key)?.reject(error);
595
+ }
596
+ throw error;
367
597
  }
368
- return existing;
598
+ }
599
+ uniqueChunks(chunks) {
600
+ const seen = new Set();
601
+ const output = [];
602
+ for (const chunk of chunks) {
603
+ const key = this.key(chunk);
604
+ if (seen.has(key))
605
+ continue;
606
+ seen.add(key);
607
+ output.push(chunk);
608
+ }
609
+ return output.sort((a, b) => a.source_offset - b.source_offset || a.source_chunk_index - b.source_chunk_index);
610
+ }
611
+ groupChunks(chunks) {
612
+ const groups = [];
613
+ let current = [];
614
+ let groupStart = 0;
615
+ let groupEnd = 0;
616
+ const maxBytes = Math.max(1, Math.floor(this.options.groupSizeBytes));
617
+ for (const chunk of chunks) {
618
+ const chunkStart = chunk.source_offset;
619
+ const chunkEnd = chunk.source_offset + chunk.chunk_size;
620
+ const startsNewGroup = current.length === 0 || chunkStart !== groupEnd || chunkEnd - groupStart > maxBytes;
621
+ if (startsNewGroup) {
622
+ if (current.length)
623
+ groups.push(current);
624
+ current = [chunk];
625
+ groupStart = chunkStart;
626
+ groupEnd = chunkEnd;
627
+ }
628
+ else {
629
+ current.push(chunk);
630
+ groupEnd = chunkEnd;
631
+ }
632
+ }
633
+ if (current.length)
634
+ groups.push(current);
635
+ return groups;
636
+ }
637
+ key(chunk) {
638
+ return `${chunk.source_id}:${chunk.source_chunk_index}`;
369
639
  }
370
640
  run(task) {
371
641
  return new Promise((resolve, reject) => {
@@ -382,6 +652,20 @@ class ChunkChecksumCache {
382
652
  this.queue.push(start);
383
653
  });
384
654
  }
655
+ emit(event) {
656
+ if (!this.options.onEvent)
657
+ return;
658
+ void Promise.resolve(this.options.onEvent(event)).catch(() => undefined);
659
+ }
660
+ }
661
+ function deferredChecksum() {
662
+ let resolve;
663
+ let reject;
664
+ const promise = new Promise((res, rej) => {
665
+ resolve = res;
666
+ reject = rej;
667
+ });
668
+ return { promise, resolve, reject };
385
669
  }
386
670
  export function calculateOptimalChunkSize(totalSize) {
387
671
  const mb = 1024 * 1024;
package/dist/models.d.ts CHANGED
@@ -44,6 +44,7 @@ export interface TransferCreateRequest {
44
44
  chunk_hashes?: string[];
45
45
  callbacks?: CallbackConfig[];
46
46
  test_mode?: boolean;
47
+ experimental?: boolean;
47
48
  progressive_mode?: boolean;
48
49
  signed_url_flow?: SignedUrlFlow;
49
50
  }
@@ -60,12 +61,17 @@ export interface RawTransferCreateInput {
60
61
  * Maps to BeamCore test_mode.
61
62
  */
62
63
  testMode?: boolean;
64
+ /**
65
+ * Experimental transfers keep positive evidence but exclude failures and overseer interventions from PRISM.
66
+ */
67
+ experimental?: boolean;
63
68
  progressiveMode?: boolean;
64
69
  signedUrlFlow?: SignedUrlFlow;
65
70
  }
66
71
  export interface TransferCreateResponse {
67
72
  success: boolean;
68
73
  transfer_id: string;
74
+ experimental?: boolean;
69
75
  total_chunks: number;
70
76
  total_sources: number;
71
77
  total_destinations: number;
@@ -109,6 +115,7 @@ export interface TransferStatusInfo {
109
115
  transfer_id: string;
110
116
  status: string;
111
117
  error_message: string | null;
118
+ experimental?: boolean;
112
119
  started_at: string | null;
113
120
  completed_at: string | null;
114
121
  }
@@ -157,6 +164,26 @@ export interface HippiusProviderConfig {
157
164
  }
158
165
  export type ProviderConfig = S3ProviderConfig | R2ProviderConfig | S3CompatibleProviderConfig | HippiusProviderConfig;
159
166
  export type ProviderSourceConfig = ProviderConfig;
167
+ export interface ChunkChecksumProviderGetInput {
168
+ source: ProviderSourceConfig;
169
+ chunk: ChunkSigningPlanItem;
170
+ algorithm: "content-md5";
171
+ }
172
+ export interface ChunkChecksumProviderSetInput extends ChunkChecksumProviderGetInput {
173
+ contentMd5: string;
174
+ }
175
+ export interface ChunkChecksumProvider {
176
+ getContentMd5(input: ChunkChecksumProviderGetInput): Promise<string | null | undefined>;
177
+ setContentMd5?(input: ChunkChecksumProviderSetInput): Promise<void>;
178
+ }
179
+ export interface ChunkChecksumEvent {
180
+ event: "checksum_warmup_started" | "checksum_warmup_completed" | "checksum_cache_hit" | "checksum_cache_miss" | "checksum_cold_chunk_cached" | "checksum_cold_group_completed";
181
+ sourceId: string;
182
+ sourceChunkIndex?: number;
183
+ chunkCount?: number;
184
+ bytes?: number;
185
+ durationMs?: number;
186
+ }
160
187
  export type ProviderDestinationConfig = ProviderConfig;
161
188
  export type S3ProviderConfigInput = Omit<S3ProviderConfig, "provider">;
162
189
  export type R2ProviderConfigInput = Omit<R2ProviderConfig, "provider">;
@@ -182,26 +209,39 @@ export interface ProviderTransferCreateInput {
182
209
  * Maps to BeamCore test_mode.
183
210
  */
184
211
  testMode?: boolean;
212
+ /**
213
+ * Experimental transfers keep positive evidence but exclude failures and overseer interventions from PRISM.
214
+ */
215
+ experimental?: boolean;
185
216
  expiresIn?: number;
186
217
  /**
187
218
  * Defaults to true. Set to false to only prepare and attach signed URLs.
188
219
  */
189
220
  distribute?: boolean;
221
+ onPlanned?: (plan: TransferPlanResponse) => void | Promise<void>;
222
+ onBeforeTransferPrepare?: () => void | Promise<void>;
190
223
  onPrepared?: (prepared: TransferPrepareResponse) => void | Promise<void>;
191
- throwIfCancelled?: (transferId: string) => void | Promise<void>;
224
+ throwIfCancelled?: (transferId?: string) => void | Promise<void>;
192
225
  signedUrlFlow?: SignedUrlFlow;
226
+ checksumProvider?: ChunkChecksumProvider;
227
+ checksumReadConcurrency?: number;
228
+ checksumGroupSizeBytes?: number;
229
+ onChecksumEvent?: (event: ChunkChecksumEvent) => void | Promise<void>;
193
230
  }
194
- export interface PreparedHttpSource {
231
+ export interface PlanningHttpSource {
195
232
  source_id: string;
196
233
  type: "http";
197
234
  provider?: string;
198
- url: string;
235
+ url?: string;
199
236
  size: number;
200
237
  filename?: string;
201
238
  headers?: Record<string, string>;
202
239
  expires_at?: string;
203
240
  metadata?: Record<string, unknown>;
204
241
  }
242
+ export interface PreparedHttpSource extends PlanningHttpSource {
243
+ url: string;
244
+ }
205
245
  export interface PreparedDestination {
206
246
  destination_id: string;
207
247
  provider: string;
@@ -237,11 +277,25 @@ export interface SignedChunkRoute {
237
277
  dest_headers?: Record<string, string>;
238
278
  metadata?: Record<string, unknown>;
239
279
  }
280
+ export interface TransferPlanResponse {
281
+ success: boolean;
282
+ chunk_size?: number;
283
+ total_size?: number;
284
+ total_sources?: number;
285
+ total_destinations?: number;
286
+ logical_chunks?: number;
287
+ total_chunks?: number;
288
+ chunk_plan: ChunkSigningPlanItem[];
289
+ signed_url_flow?: SignedUrlFlow;
290
+ error?: string;
291
+ message?: string;
292
+ }
240
293
  export interface TransferPrepareResponse {
241
294
  success: boolean;
242
295
  transfer_id: string;
243
296
  transfer_key?: string;
244
297
  test_mode?: boolean;
298
+ experimental?: boolean;
245
299
  chunk_size?: number;
246
300
  total_size?: number;
247
301
  total_sources?: number;
@@ -1,4 +1,4 @@
1
- import type { ChunkDestinationSigningTarget, ChunkSigningPlanItem, PreparedDestination, PreparedHttpSource, ProviderDestinationConfig, ProviderSourceConfig, R2ProviderConfig, S3CompatibleProviderConfig, S3ProviderConfig, SignedChunkRoute } from "./models.js";
1
+ import type { ChunkDestinationSigningTarget, ChunkSigningPlanItem, PlanningHttpSource, PreparedDestination, PreparedHttpSource, ProviderDestinationConfig, ProviderSourceConfig, R2ProviderConfig, S3CompatibleProviderConfig, S3ProviderConfig, SignedChunkRoute } from "./models.js";
2
2
  export type AnyS3CompatibleProviderConfig = S3ProviderConfig | R2ProviderConfig | S3CompatibleProviderConfig;
3
3
  export declare function nowIso(): string;
4
4
  export declare function expiresAtIso(expiresIn: number): string;
@@ -7,6 +7,10 @@ export declare function prepareProviderSource(source: ProviderSourceConfig, opti
7
7
  expiresIn?: number;
8
8
  fetchImpl?: typeof fetch;
9
9
  }): Promise<PreparedHttpSource>;
10
+ export declare function prepareProviderSourceForPlan(source: ProviderSourceConfig, options?: {
11
+ index?: number;
12
+ fetchImpl?: typeof fetch;
13
+ }): Promise<PlanningHttpSource>;
10
14
  export declare function prepareProviderDestination(destination: ProviderDestinationConfig, options?: {
11
15
  index?: number;
12
16
  }): PreparedDestination;
@@ -51,6 +55,13 @@ export declare function signDestinationRoute(input: {
51
55
  deleteUrl?: string;
52
56
  fetchImpl?: typeof fetch;
53
57
  }): Promise<SignedChunkRoute>;
58
+ export declare function computeChunksContentMd5(input: {
59
+ source: ProviderSourceConfig;
60
+ chunks: ChunkSigningPlanItem[];
61
+ expiresIn: number;
62
+ fetchImpl?: typeof fetch;
63
+ groupSizeBytes?: number;
64
+ }): Promise<Map<number, string>>;
54
65
  export declare function computeChunkContentMd5(input: {
55
66
  source: ProviderSourceConfig;
56
67
  chunk: ChunkSigningPlanItem;
@@ -23,7 +23,13 @@ export async function prepareProviderSource(source, options = {}) {
23
23
  size: Number(head.ContentLength ?? 0),
24
24
  filename: filename(source.key),
25
25
  expires_at: expiresAtIso(expiresIn),
26
- metadata: s3CompatibleMetadata(source, endpoint)
26
+ metadata: {
27
+ ...s3CompatibleMetadata(source, endpoint),
28
+ content_length: Number(head.ContentLength ?? 0),
29
+ ...(head.ETag ? { etag: head.ETag } : {}),
30
+ ...(head.LastModified ? { last_modified: head.LastModified.toISOString() } : {}),
31
+ ...(head.VersionId ? { version_id: head.VersionId } : {})
32
+ }
27
33
  };
28
34
  }
29
35
  if (isHippiusProvider(source)) {
@@ -44,6 +50,42 @@ export async function prepareProviderSource(source, options = {}) {
44
50
  }
45
51
  return unsupportedProviderConfig(source);
46
52
  }
53
+ export async function prepareProviderSourceForPlan(source, options = {}) {
54
+ const index = options.index ?? 0;
55
+ if (isS3CompatibleProvider(source)) {
56
+ const endpoint = s3CompatibleEndpoint(source);
57
+ const client = createS3CompatibleClient(source, endpoint);
58
+ const head = await client.send(new HeadObjectCommand({ Bucket: source.bucket, Key: source.key }));
59
+ return {
60
+ source_id: source.id ?? `src_${index}`,
61
+ type: "http",
62
+ provider: source.provider,
63
+ size: Number(head.ContentLength ?? 0),
64
+ filename: filename(source.key),
65
+ metadata: {
66
+ ...s3CompatibleMetadata(source, endpoint),
67
+ content_length: Number(head.ContentLength ?? 0),
68
+ ...(head.ETag ? { etag: head.ETag } : {}),
69
+ ...(head.LastModified ? { last_modified: head.LastModified.toISOString() } : {}),
70
+ ...(head.VersionId ? { version_id: head.VersionId } : {})
71
+ }
72
+ };
73
+ }
74
+ if (isHippiusProvider(source)) {
75
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
76
+ const baseUrl = source.base_url ?? "https://api.hippius.com";
77
+ const size = await hippiusObjectSize(fetchImpl, baseUrl, source.api_token, source.bucket, source.key);
78
+ return {
79
+ source_id: source.id ?? `src_${index}`,
80
+ type: "http",
81
+ provider: "hippius",
82
+ size,
83
+ filename: filename(source.key),
84
+ metadata: { bucket: source.bucket, key: source.key, base_url: baseUrl }
85
+ };
86
+ }
87
+ return unsupportedProviderConfig(source);
88
+ }
47
89
  export function prepareProviderDestination(destination, options = {}) {
48
90
  const index = options.index ?? 0;
49
91
  const destinationId = destination.id ?? `dst_${index}`;
@@ -221,8 +263,11 @@ export async function signDestinationRoute(input) {
221
263
  };
222
264
  }
223
265
  function rangeHeaderForChunk(chunk) {
224
- const start = Math.max(0, Math.floor(chunk.source_offset));
225
- const size = Math.max(1, Math.floor(chunk.chunk_size));
266
+ return rangeHeaderForRange(chunk.source_offset, chunk.chunk_size);
267
+ }
268
+ function rangeHeaderForRange(sourceOffset, chunkSize) {
269
+ const start = Math.max(0, Math.floor(sourceOffset));
270
+ const size = Math.max(1, Math.floor(chunkSize));
226
271
  return `bytes=${start}-${start + size - 1}`;
227
272
  }
228
273
  async function signSourceRoute(input) {
@@ -249,6 +294,27 @@ async function signSourceRoute(input) {
249
294
  }
250
295
  return unsupportedProviderConfig(input.source);
251
296
  }
297
+ async function signSourceRangeRoute(input) {
298
+ const range = rangeHeaderForRange(input.sourceOffset, input.chunkSize);
299
+ if (isS3CompatibleProvider(input.source)) {
300
+ const client = createS3CompatibleClient(input.source);
301
+ return {
302
+ url: await getSignedUrl(client, new GetObjectCommand({
303
+ Bucket: input.source.bucket,
304
+ Key: input.source.key,
305
+ Range: range
306
+ }), { expiresIn: input.expiresIn }),
307
+ headers: { Range: range }
308
+ };
309
+ }
310
+ if (isHippiusProvider(input.source)) {
311
+ return {
312
+ url: await hippiusPresign(input.fetchImpl ?? globalThis.fetch, input.source.base_url ?? "https://api.hippius.com", input.source.api_token, input.source.bucket, input.source.key, "get", input.expiresIn),
313
+ headers: { Range: range }
314
+ };
315
+ }
316
+ return { url: input.fallbackUrl, headers: { Range: range } };
317
+ }
252
318
  async function signDestinationUrl(input) {
253
319
  if (isS3CompatibleProvider(input.destination)) {
254
320
  const client = createS3CompatibleClient(input.destination);
@@ -268,27 +334,232 @@ async function signDestinationUrl(input) {
268
334
  }
269
335
  return unsupportedProviderConfig(input.destination);
270
336
  }
337
+ export async function computeChunksContentMd5(input) {
338
+ const output = new Map();
339
+ const chunks = [...input.chunks].sort((a, b) => a.source_offset - b.source_offset || a.source_chunk_index - b.source_chunk_index);
340
+ const groups = groupContiguousChunks(chunks, input.groupSizeBytes ?? 256 * 1024 * 1024);
341
+ for (const group of groups) {
342
+ const groupOutput = await retryChecksumRead(() => computeChunkGroupContentMd5({
343
+ source: input.source,
344
+ chunks: group,
345
+ expiresIn: input.expiresIn,
346
+ fetchImpl: input.fetchImpl
347
+ }));
348
+ for (const [sourceChunkIndex, checksum] of groupOutput) {
349
+ output.set(sourceChunkIndex, checksum);
350
+ }
351
+ }
352
+ return output;
353
+ }
271
354
  export async function computeChunkContentMd5(input) {
272
- const sourceRoute = await signSourceRoute({
355
+ const checksums = await computeChunksContentMd5({
273
356
  source: input.source,
274
- fallbackUrl: input.chunk.source_url,
275
- chunk: input.chunk,
357
+ chunks: [input.chunk],
358
+ expiresIn: input.expiresIn,
359
+ fetchImpl: input.fetchImpl,
360
+ groupSizeBytes: input.chunk.chunk_size
361
+ });
362
+ const checksum = checksums.get(input.chunk.source_chunk_index);
363
+ if (!checksum) {
364
+ throw new Error(`chunk checksum missing for source chunk ${input.chunk.source_chunk_index}`);
365
+ }
366
+ return checksum;
367
+ }
368
+ function groupContiguousChunks(chunks, maxGroupSizeBytes) {
369
+ const groups = [];
370
+ let current = [];
371
+ let groupStart = 0;
372
+ let groupEnd = 0;
373
+ const maxBytes = Math.max(1, Math.floor(maxGroupSizeBytes));
374
+ for (const chunk of chunks) {
375
+ const chunkStart = Math.max(0, Math.floor(chunk.source_offset));
376
+ const chunkEnd = chunkStart + Math.max(1, Math.floor(chunk.chunk_size));
377
+ const startsNewGroup = current.length === 0 ||
378
+ chunkStart !== groupEnd ||
379
+ chunkEnd - groupStart > maxBytes;
380
+ if (startsNewGroup) {
381
+ if (current.length)
382
+ groups.push(current);
383
+ current = [chunk];
384
+ groupStart = chunkStart;
385
+ groupEnd = chunkEnd;
386
+ continue;
387
+ }
388
+ current.push(chunk);
389
+ groupEnd = chunkEnd;
390
+ }
391
+ if (current.length)
392
+ groups.push(current);
393
+ return groups;
394
+ }
395
+ async function computeChunkGroupContentMd5(input) {
396
+ if (!input.chunks.length)
397
+ return new Map();
398
+ const chunks = [...input.chunks].sort((a, b) => a.source_offset - b.source_offset || a.source_chunk_index - b.source_chunk_index);
399
+ const first = chunks[0];
400
+ const last = chunks[chunks.length - 1];
401
+ const sourceOffset = first.source_offset;
402
+ const totalSize = last.source_offset + last.chunk_size - sourceOffset;
403
+ if (isS3CompatibleProvider(input.source)) {
404
+ const client = createS3CompatibleClient(input.source);
405
+ const response = await client.send(new GetObjectCommand({
406
+ Bucket: input.source.bucket,
407
+ Key: input.source.key,
408
+ Range: `bytes=${sourceOffset}-${sourceOffset + totalSize - 1}`
409
+ }));
410
+ if (!response.Body) {
411
+ throw new Error(`chunk checksum read returned no body for ${input.source.bucket}/${input.source.key}`);
412
+ }
413
+ return hashChunkGroupContentMd5(chunks, totalSize, bodyToAsyncBytes(response.Body));
414
+ }
415
+ const sourceRoute = await signSourceRangeRoute({
416
+ source: input.source,
417
+ fallbackUrl: first.source_url,
418
+ sourceOffset,
419
+ chunkSize: totalSize,
276
420
  expiresIn: input.expiresIn,
277
421
  fetchImpl: input.fetchImpl
278
422
  });
279
423
  const fetchImpl = input.fetchImpl ?? globalThis.fetch;
280
424
  const response = await fetchImpl(sourceRoute.url, { method: "GET", headers: sourceRoute.headers });
281
425
  if (!response.ok) {
282
- throw new Error(`chunk checksum fetch failed with ${response.status}: ${await response.text()}`);
426
+ const message = `chunk checksum fetch failed with ${response.status}: ${await response.text()}`;
427
+ throw Object.assign(new Error(message), { status: response.status });
428
+ }
429
+ if (response.body) {
430
+ return hashChunkGroupContentMd5(chunks, totalSize, readableStreamToAsyncBytes(response.body));
431
+ }
432
+ return hashChunkGroupContentMd5(chunks, totalSize, [new Uint8Array(await response.arrayBuffer())]);
433
+ }
434
+ async function hashChunkGroupContentMd5(chunks, totalSize, body) {
435
+ const output = new Map();
436
+ let chunkIndex = 0;
437
+ let bytesInChunk = 0;
438
+ let totalRead = 0;
439
+ let hash = createHash("md5");
440
+ const consume = (value) => {
441
+ let offset = 0;
442
+ while (offset < value.byteLength) {
443
+ const chunk = chunks[chunkIndex];
444
+ if (!chunk) {
445
+ throw new Error(`chunk checksum fetch size mismatch: read more than ${totalSize} bytes`);
446
+ }
447
+ const remainingInChunk = chunk.chunk_size - bytesInChunk;
448
+ const take = Math.min(remainingInChunk, value.byteLength - offset);
449
+ hash.update(value.subarray(offset, offset + take));
450
+ offset += take;
451
+ totalRead += take;
452
+ bytesInChunk += take;
453
+ if (bytesInChunk === chunk.chunk_size) {
454
+ output.set(chunk.source_chunk_index, hash.digest("base64"));
455
+ chunkIndex += 1;
456
+ bytesInChunk = 0;
457
+ hash = createHash("md5");
458
+ }
459
+ }
460
+ };
461
+ for await (const value of body) {
462
+ consume(value);
463
+ }
464
+ if (totalRead !== totalSize || output.size !== chunks.length) {
465
+ throw new Error(`chunk checksum fetch size mismatch: expected ${totalSize}, got ${totalRead}`);
466
+ }
467
+ return output;
468
+ }
469
+ async function* readableStreamToAsyncBytes(stream) {
470
+ const reader = stream.getReader();
471
+ try {
472
+ while (true) {
473
+ const { done, value } = await reader.read();
474
+ if (done)
475
+ break;
476
+ if (value)
477
+ yield value;
478
+ }
479
+ }
480
+ finally {
481
+ reader.releaseLock();
283
482
  }
284
- const body = Buffer.from(await response.arrayBuffer());
285
- if (body.length !== input.chunk.chunk_size) {
286
- throw new Error(`chunk checksum fetch size mismatch: expected ${input.chunk.chunk_size}, got ${body.length}`);
483
+ }
484
+ async function* bodyToAsyncBytes(body) {
485
+ if (body instanceof Uint8Array) {
486
+ yield body;
487
+ return;
287
488
  }
288
- return createHash("md5").update(body).digest("base64");
489
+ if (typeof body === "string") {
490
+ yield new TextEncoder().encode(body);
491
+ return;
492
+ }
493
+ if (body && typeof body.transformToByteArray === "function") {
494
+ yield new Uint8Array(await body.transformToByteArray());
495
+ return;
496
+ }
497
+ if (body && typeof body.arrayBuffer === "function") {
498
+ yield new Uint8Array(await body.arrayBuffer());
499
+ return;
500
+ }
501
+ if (body && typeof body.getReader === "function") {
502
+ yield* readableStreamToAsyncBytes(body);
503
+ return;
504
+ }
505
+ if (body && Symbol.asyncIterator in Object(body)) {
506
+ for await (const value of body) {
507
+ yield toUint8Array(value);
508
+ }
509
+ return;
510
+ }
511
+ throw new Error("provider checksum read returned an unsupported body stream");
512
+ }
513
+ function toUint8Array(value) {
514
+ if (value instanceof Uint8Array)
515
+ return value;
516
+ if (typeof value === "string")
517
+ return new TextEncoder().encode(value);
518
+ if (ArrayBuffer.isView(value)) {
519
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
520
+ }
521
+ if (value instanceof ArrayBuffer)
522
+ return new Uint8Array(value);
523
+ throw new Error("provider checksum stream yielded an unsupported chunk");
524
+ }
525
+ async function retryChecksumRead(task) {
526
+ let lastError;
527
+ for (let attempt = 0; attempt < 3; attempt += 1) {
528
+ try {
529
+ return await task();
530
+ }
531
+ catch (error) {
532
+ lastError = error;
533
+ const status = typeof error === "object" && error && "status" in error ? Number(error.status) : undefined;
534
+ if (status && status >= 400 && status < 500 && status !== 408 && status !== 429)
535
+ break;
536
+ if (attempt < 2)
537
+ await sleep(250 * (attempt + 1));
538
+ }
539
+ }
540
+ throw lastError;
541
+ }
542
+ function sleep(ms) {
543
+ return new Promise((resolve) => setTimeout(resolve, ms));
544
+ }
545
+ const s3ClientCache = new Map();
546
+ function s3ClientCacheKey(source, endpoint) {
547
+ return JSON.stringify({
548
+ provider: source.provider,
549
+ endpoint,
550
+ region: s3CompatibleRegion(source),
551
+ forcePathStyle: s3CompatibleForcePathStyle(source, endpoint),
552
+ accessKeyId: source.access_key_id,
553
+ secretAccessKey: source.secret_access_key,
554
+ sessionToken: "session_token" in source ? source.session_token : undefined
555
+ });
289
556
  }
290
557
  function createS3CompatibleClient(source, endpoint = s3CompatibleEndpoint(source)) {
291
- return new S3Client({
558
+ const cacheKey = s3ClientCacheKey(source, endpoint);
559
+ const cached = s3ClientCache.get(cacheKey);
560
+ if (cached)
561
+ return cached;
562
+ const client = new S3Client({
292
563
  region: s3CompatibleRegion(source),
293
564
  endpoint,
294
565
  forcePathStyle: s3CompatibleForcePathStyle(source, endpoint),
@@ -298,6 +569,8 @@ function createS3CompatibleClient(source, endpoint = s3CompatibleEndpoint(source
298
569
  sessionToken: "session_token" in source ? source.session_token : undefined
299
570
  }
300
571
  });
572
+ s3ClientCache.set(cacheKey, client);
573
+ return client;
301
574
  }
302
575
  export function isS3CompatibleProvider(config) {
303
576
  if (config.provider === "s3" || config.provider === "r2") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beam-network/sdk",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "TypeScript SDK for BEAM transfer creation and management.",
5
5
  "type": "module",
6
6
  "license": "MIT",