@beam-network/sdk 0.5.0 → 0.5.1

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.js CHANGED
@@ -1,10 +1,17 @@
1
1
  import { abortMultipartUpload, createMultipartUpload, expiresAtIso, prepareProviderDestination, prepareProviderSource, signAbortMultipartUpload, signCompleteMultipartUpload, signDeleteObject, signDestinationRoute, signFinalObjectHead, signListMultipartUpload, signListObjects, signUploadPartCopy } from "./provider-signing.js";
2
- import { BeamTransferControl, BEAM_DEFAULT_NATS_URL, compactSignedRoutes } from "./nats-control.js";
2
+ import { BeamTransferControl, BEAM_DEFAULT_NATS_URL, compactSignedRoutes, isRecoverableRouteStreamError } from "./nats-control.js";
3
3
  import { multipartPartNumber } from "./multipart-limits.js";
4
4
  export { BEAM_DEFAULT_NATS_URL };
5
- // The error classes live in ./errors.js and are re-exported by ./index.js.
6
- // Re-exporting them here too would make `export *` in index.ts ambiguous, which
7
- // silently drops the names from the package's public types.
5
+ export class BeamApiError extends Error {
6
+ status;
7
+ body;
8
+ constructor(message, status, body) {
9
+ super(message);
10
+ this.status = status;
11
+ this.body = body;
12
+ this.name = "BeamApiError";
13
+ }
14
+ }
8
15
  export class BeamClient {
9
16
  apiKey;
10
17
  natsUrl;
@@ -12,10 +19,6 @@ export class BeamClient {
12
19
  control;
13
20
  routeSigningConcurrency;
14
21
  routeSigningConcurrencyOverridden;
15
- /** Last credit state Beam reported. Null until the first request or connect. */
16
- get creditStatus() {
17
- return this.control.creditStatus;
18
- }
19
22
  constructor(options = {}) {
20
23
  if (!options.apiKey?.trim()) {
21
24
  throw new Error("apiKey is required.");
@@ -32,8 +35,7 @@ export class BeamClient {
32
35
  subjectPrefix: options.transferClientSubjectPrefix,
33
36
  transferRuntimeShardCount: options.transferRuntimeShardCount,
34
37
  requestTimeoutMs: options.requestTimeoutMs,
35
- maxPayloadBytes: options.maxPayloadBytes,
36
- onCreditWarning: options.onCreditWarning
38
+ maxPayloadBytes: options.maxPayloadBytes
37
39
  });
38
40
  this.fetchImpl = options.fetch ?? globalThis.fetch;
39
41
  if (!this.fetchImpl) {
@@ -71,9 +73,12 @@ export class BeamClient {
71
73
  createTransfer(input) {
72
74
  return this.prepareProviderTransfer(input);
73
75
  }
74
- transferStatus(transferId) {
76
+ async transferStatus(transferId) {
75
77
  validateId(transferId, "transferId");
76
- return this.control.request("transfer.status", { transfer_id: transferId }, { transferId });
78
+ const status = await this.control.request("transfer.status", { transfer_id: transferId }, { transferId });
79
+ if (["completed", "failed", "cancelled"].includes(status.status))
80
+ this.control.releaseRecoveryLease(transferId);
81
+ return status;
77
82
  }
78
83
  distributeTransfer(transferId) {
79
84
  validateId(transferId, "transferId");
@@ -88,6 +93,7 @@ export class BeamClient {
88
93
  transferId,
89
94
  idempotencyKey: `transfer:${transferId}:cancel`
90
95
  });
96
+ this.control.releaseRecoveryLease(transferId);
91
97
  return result;
92
98
  }
93
99
  async planTransfer(input) {
@@ -105,8 +111,10 @@ export class BeamClient {
105
111
  }
106
112
  async prepareTransfer(input) {
107
113
  const transferId = await transferIdForIdempotencyKey(input.idempotencyKey);
114
+ const routeGenerationId = input.routeGenerationId ?? randomUuid();
108
115
  const result = await this.control.request("transfer.prepare", compact({
109
116
  transfer_id: transferId,
117
+ route_generation_id: routeGenerationId,
110
118
  sources: input.sources,
111
119
  destinations: input.destinations,
112
120
  name: input.name,
@@ -125,56 +133,97 @@ export class BeamClient {
125
133
  validateId(transferId, "transferId");
126
134
  validateMultipartGroupManifest(input.multipartGroupManifest, transferId);
127
135
  validateSignedRouteManifestContract(input.chunkRoutes, input.multipartGroupManifest);
128
- const destinationCount = new Set(input.chunkRoutes.map((route) => route.destination_id)).size;
129
- const orderedRoutes = input.chunkRoutes
130
- .map((route) => {
131
- if (signedRouteDeliveryIndex(route) !== undefined)
132
- return route;
133
- if (destinationCount !== 1) {
134
- throw new Error("delivery_index is required when manually attaching routes for multiple destinations");
135
- }
136
- return { ...route, delivery_index: route.chunk_index };
137
- })
138
- .sort(compareSignedRoutesByDelivery);
139
- const streamId = await stableRouteStreamId({
140
- transferId,
141
- planIdentity: await routeCoordinateChecksum(orderedRoutes),
142
- totalRoutes: orderedRoutes.length,
143
- totalChunks: countDistinctRouteChunks(orderedRoutes),
144
- signedUrlFlow: input.multipartGroupManifest.length ? "signed_url_v2" : "signed_url_v1"
145
- });
146
- const stream = new RouteStreamSender(this.control, {
147
- streamId,
136
+ const streamRoutes = async (chunkRoutes, multipartGroupManifest, routeGenerationId, urlsExpiresAt) => {
137
+ validateMultipartGroupManifest(multipartGroupManifest, transferId);
138
+ validateSignedRouteManifestContract(chunkRoutes, multipartGroupManifest);
139
+ const destinationCount = new Set(chunkRoutes.map((route) => route.destination_id)).size;
140
+ const orderedRoutes = chunkRoutes.map((route) => {
141
+ if (signedRouteDeliveryIndex(route) !== undefined)
142
+ return route;
143
+ if (destinationCount !== 1) {
144
+ throw new Error("delivery_index is required when manually attaching routes for multiple destinations");
145
+ }
146
+ return { ...route, delivery_index: route.chunk_index };
147
+ })
148
+ .sort(compareSignedRoutesByDelivery);
149
+ const streamId = await stableRouteStreamId({
150
+ transferId,
151
+ planIdentity: `${await routeCoordinateChecksum(orderedRoutes)}:${routeGenerationId}`,
152
+ totalRoutes: orderedRoutes.length,
153
+ totalChunks: countDistinctRouteChunks(orderedRoutes),
154
+ signedUrlFlow: multipartGroupManifest.length ? "signed_url_v2" : "signed_url_v1"
155
+ });
156
+ const stream = new RouteStreamSender(this.control, {
157
+ streamId,
158
+ transferId,
159
+ routeGenerationId,
160
+ totalRoutes: orderedRoutes.length,
161
+ totalChunks: countDistinctRouteChunks(orderedRoutes),
162
+ urlsExpiresAt,
163
+ signedUrlFlow: multipartGroupManifest.length ? "signed_url_v2" : "signed_url_v1",
164
+ autoDistribute: input.autoDistribute ?? true
165
+ });
166
+ await stream.begin();
167
+ if (multipartGroupManifest.length)
168
+ await stream.addManifestGroups(multipartGroupManifest);
169
+ for (const route of orderedRoutes)
170
+ await stream.addRoute(route);
171
+ return stream.complete();
172
+ };
173
+ const routeStreamLock = new AsyncMutex();
174
+ const releaseInitialStream = await routeStreamLock.acquire();
175
+ this.control.registerRecoveryLease({
148
176
  transferId,
149
- totalRoutes: orderedRoutes.length,
150
- totalChunks: countDistinctRouteChunks(orderedRoutes),
151
- urlsExpiresAt: input.urlsExpiresAt,
152
- signedUrlFlow: input.multipartGroupManifest.length ? "signed_url_v2" : "signed_url_v1",
153
- autoDistribute: input.autoDistribute ?? true
177
+ planFingerprint: input.planFingerprint,
178
+ coordinateChecksum: input.coordinateChecksum,
179
+ replayRoutes: async (routeGenerationId) => {
180
+ await routeStreamLock.run(async () => {
181
+ const fresh = await input.recoveryFactory(routeGenerationId);
182
+ const attached = await streamRoutes(fresh.chunkRoutes, fresh.multipartGroupManifest, routeGenerationId, fresh.urlsExpiresAt);
183
+ if (!attached.success)
184
+ throw new Error(attached.error ?? attached.message ?? "route replay failed");
185
+ });
186
+ }
154
187
  });
155
- await stream.begin();
156
- if (input.multipartGroupManifest.length) {
157
- await stream.addManifestGroups(input.multipartGroupManifest);
188
+ try {
189
+ const attached = await streamRoutes(input.chunkRoutes, input.multipartGroupManifest, input.routeGenerationId, input.urlsExpiresAt);
190
+ if (!attached.success)
191
+ this.control.releaseRecoveryLease(transferId);
192
+ return attached;
158
193
  }
159
- for (const route of orderedRoutes) {
160
- await stream.addRoute(route);
194
+ catch (error) {
195
+ if (isRecoverableRouteStreamError(error))
196
+ this.control.continueRecoveryLease(transferId);
197
+ else
198
+ this.control.releaseRecoveryLease(transferId);
199
+ throw error;
200
+ }
201
+ finally {
202
+ releaseInitialStream();
161
203
  }
162
- return stream.complete();
163
204
  }
164
205
  async prepareProviderTransfer(input) {
165
206
  const expiresIn = input.expiresIn ?? 3600;
166
207
  const requestedSignedUrlFlow = input.signedUrlFlow ?? "signed_url_v1";
167
- const preparedDestinations = input.destinations.map((destination, index) => prepareProviderDestination(destination, { index }));
208
+ const retainedRecoveryInput = {
209
+ ...input,
210
+ sources: input.sources.map((source) => ({ ...source })),
211
+ destinations: input.destinations.map((destination) => ({ ...destination })),
212
+ onBeforeTransferPrepare: undefined,
213
+ onPrepared: undefined,
214
+ throwIfCancelled: undefined
215
+ };
216
+ const preparedDestinations = retainedRecoveryInput.destinations.map((destination, index) => prepareProviderDestination(destination, { index }));
168
217
  const destinationsById = new Map();
169
218
  preparedDestinations.forEach((preparedDestination, index) => {
170
- const destination = input.destinations[index];
219
+ const destination = retainedRecoveryInput.destinations[index];
171
220
  if (!destination) {
172
221
  throw new Error(`missing provider destination config for prepared destination ${preparedDestination.destination_id}`);
173
222
  }
174
223
  destinationsById.set(preparedDestination.destination_id, destination);
175
224
  });
176
225
  await input.throwIfCancelled?.();
177
- const preparedSources = await Promise.all(input.sources.map((source, index) => prepareProviderSource(source, { index, expiresIn, fetchImpl: this.fetchImpl })));
226
+ const preparedSources = await Promise.all(retainedRecoveryInput.sources.map((source, index) => prepareProviderSource(source, { index, expiresIn, fetchImpl: this.fetchImpl })));
178
227
  await input.throwIfCancelled?.();
179
228
  await input.onBeforeTransferPrepare?.();
180
229
  const prepared = await this.prepareTransfer({
@@ -183,145 +232,201 @@ export class BeamClient {
183
232
  name: input.name,
184
233
  testMode: input.testMode,
185
234
  signedUrlFlow: requestedSignedUrlFlow,
186
- idempotencyKey: input.idempotencyKey
235
+ idempotencyKey: input.idempotencyKey,
236
+ routeGenerationId: input.routeGenerationId
187
237
  });
188
238
  if (!prepared.success) {
189
239
  return prepared;
190
240
  }
191
- await input.onPrepared?.(prepared);
192
- await input.throwIfCancelled?.(prepared.transfer_id);
193
241
  const sourcesById = new Map();
194
242
  preparedSources.forEach((preparedSource, index) => {
195
- const source = input.sources[index];
243
+ const source = retainedRecoveryInput.sources[index];
196
244
  if (!source) {
197
245
  throw new Error(`missing provider source config for prepared source ${preparedSource.source_id}`);
198
246
  }
199
247
  sourcesById.set(preparedSource.source_id, source);
200
248
  });
201
249
  const multipartUploads = new Map();
202
- const pendingRoutes = new Set();
203
- let signingConcurrency = this.routeSigningConcurrency;
204
- let signedInWindow = 0;
205
- let signingWindowStartedAt = performance.now();
206
- let routeStream = null;
207
- let routeStreamBeginAttempted = false;
208
- let multipartManifestTask = null;
209
- let multipartGroupWaiters = new Map();
210
- try {
211
- const streamId = await stableRouteStreamId({
212
- transferId: prepared.transfer_id,
213
- planIdentity: prepared.plan_descriptor.plan_nonce,
214
- totalRoutes: prepared.plan_descriptor.delivery_route_count,
215
- totalChunks: prepared.plan_descriptor.logical_chunk_count,
216
- signedUrlFlow: requestedSignedUrlFlow
217
- });
218
- routeStream = new RouteStreamSender(this.control, {
219
- streamId,
220
- transferId: prepared.transfer_id,
221
- totalRoutes: prepared.plan_descriptor.delivery_route_count,
222
- totalChunks: prepared.plan_descriptor.logical_chunk_count,
223
- signedUrlFlow: requestedSignedUrlFlow,
224
- autoDistribute: input.distribute !== false
225
- });
226
- routeStreamBeginAttempted = true;
227
- await routeStream.begin();
228
- multipartGroupWaiters = createMultipartGroupWaiters(prepared, destinationsById);
229
- multipartManifestTask = createMultipartGroupManifest({
230
- prepared,
231
- destinationsById,
232
- multipartUploads,
233
- expiresIn,
234
- signedUrlFlow: requestedSignedUrlFlow,
235
- concurrency: this.routeSigningConcurrency,
236
- onGroupReady: async (state) => {
237
- if (requestedSignedUrlFlow === "signed_url_v2") {
238
- validateMultipartGroupManifest([state.manifest], prepared.transfer_id);
239
- await routeStream.addManifestGroups([state.manifest]);
250
+ const autoDistribute = input.distribute !== false;
251
+ let initialThrowIfCancelled = input.throwIfCancelled;
252
+ const routeStreamLock = new AsyncMutex();
253
+ const releaseInitialStream = await routeStreamLock.acquire();
254
+ const streamPreparedRoutes = async (routeGenerationId, recoveryReplay) => {
255
+ const pendingRoutes = new Set();
256
+ let signingConcurrency = this.routeSigningConcurrency;
257
+ let signedInWindow = 0;
258
+ let signingWindowStartedAt = performance.now();
259
+ let routeStream = null;
260
+ let routeStreamBeginAttempted = false;
261
+ let multipartManifestTask = null;
262
+ let multipartGroupWaiters = new Map();
263
+ let foregroundCancelled = false;
264
+ const throwIfCancelled = recoveryReplay || !initialThrowIfCancelled ? undefined : async (transferId) => {
265
+ try {
266
+ await initialThrowIfCancelled?.(transferId);
267
+ }
268
+ catch (error) {
269
+ foregroundCancelled = true;
270
+ throw error;
271
+ }
272
+ };
273
+ try {
274
+ const streamId = await stableRouteStreamId({
275
+ transferId: prepared.transfer_id,
276
+ planIdentity: `${prepared.plan_descriptor.plan_nonce}:${routeGenerationId}`,
277
+ totalRoutes: prepared.plan_descriptor.delivery_route_count,
278
+ totalChunks: prepared.plan_descriptor.logical_chunk_count,
279
+ signedUrlFlow: requestedSignedUrlFlow
280
+ });
281
+ routeStream = new RouteStreamSender(this.control, {
282
+ streamId,
283
+ transferId: prepared.transfer_id,
284
+ routeGenerationId,
285
+ totalRoutes: prepared.plan_descriptor.delivery_route_count,
286
+ totalChunks: prepared.plan_descriptor.logical_chunk_count,
287
+ signedUrlFlow: requestedSignedUrlFlow,
288
+ autoDistribute,
289
+ urlsExpiresAt: expiresAtIso(expiresIn)
290
+ });
291
+ routeStreamBeginAttempted = true;
292
+ await routeStream.begin();
293
+ multipartGroupWaiters = createMultipartGroupWaiters(prepared, destinationsById);
294
+ multipartManifestTask = createMultipartGroupManifest({
295
+ prepared,
296
+ destinationsById,
297
+ multipartUploads,
298
+ expiresIn,
299
+ signedUrlFlow: requestedSignedUrlFlow,
300
+ concurrency: this.routeSigningConcurrency,
301
+ preserveUploadsOnFailure: recoveryReplay,
302
+ onGroupReady: async (state) => {
303
+ if (requestedSignedUrlFlow === "signed_url_v2") {
304
+ validateMultipartGroupManifest([state.manifest], prepared.transfer_id);
305
+ await routeStream.addManifestGroups([state.manifest]);
306
+ }
307
+ multipartGroupWaiters.get(state.manifest.multipart_group_id)?.resolve(state);
308
+ },
309
+ onGroupFailed: (groupId, error) => multipartGroupWaiters.get(groupId)?.reject(error)
310
+ });
311
+ const streamNextCompletedRoute = async () => {
312
+ const settled = await Promise.race([...pendingRoutes].map((pending) => pending.then((route) => ({ pending, route }))));
313
+ pendingRoutes.delete(settled.pending);
314
+ await routeStream.addRoute(settled.route);
315
+ signedInWindow += 1;
316
+ if (signedInWindow === ROUTE_STREAM_BATCH_ROUTES) {
317
+ const elapsedMs = performance.now() - signingWindowStartedAt;
318
+ if (!this.routeSigningConcurrencyOverridden && elapsedMs > 4_000 && signingConcurrency < 256) {
319
+ signingConcurrency = Math.min(256, signingConcurrency * 2);
320
+ }
321
+ signedInWindow = 0;
322
+ signingWindowStartedAt = performance.now();
240
323
  }
241
- multipartGroupWaiters.get(state.manifest.multipart_group_id)?.resolve(state);
242
- },
243
- onGroupFailed: (groupId, error) => multipartGroupWaiters.get(groupId)?.reject(error)
244
- });
245
- const streamNextCompletedRoute = async () => {
246
- const settled = await Promise.race([...pendingRoutes].map((pending) => pending.then((route) => ({ pending, route }))));
247
- pendingRoutes.delete(settled.pending);
248
- await routeStream.addRoute(settled.route);
249
- signedInWindow += 1;
250
- if (signedInWindow === ROUTE_STREAM_BATCH_ROUTES) {
251
- const elapsedMs = performance.now() - signingWindowStartedAt;
252
- if (!this.routeSigningConcurrencyOverridden && elapsedMs > 4_000 && signingConcurrency < 256) {
253
- signingConcurrency = Math.min(256, signingConcurrency * 2);
324
+ };
325
+ for (const chunk of materializePlanChunks(prepared.plan_descriptor, prepared.transfer_id)) {
326
+ await throwIfCancelled?.(prepared.transfer_id);
327
+ for (const target of chunk.destinations) {
328
+ const pendingRoute = (async () => {
329
+ await throwIfCancelled?.(prepared.transfer_id);
330
+ const destination = destinationsById.get(target.destination_id);
331
+ if (!destination)
332
+ throw new Error(`BeamCore returned unknown destination_id: ${target.destination_id}`);
333
+ const source = sourcesById.get(chunk.source_id);
334
+ if (!source)
335
+ throw new Error(`BeamCore returned unknown source_id: ${chunk.source_id}`);
336
+ const metadata = target.metadata ?? {};
337
+ const finalObjectKey = typeof metadata.final_object_key === "string" ? metadata.final_object_key : target.object_key;
338
+ if (!finalObjectKey)
339
+ throw new Error("destination signing target is missing object_key");
340
+ const multipartGroupId = multipartGroupStateKey(prepared.transfer_id, target.destination_id, chunk.source_id, finalObjectKey);
341
+ const upload = isHippiusDestination(destination)
342
+ ? undefined
343
+ : await multipartGroupWaiters.get(multipartGroupId)?.promise;
344
+ if (!isHippiusDestination(destination) && !upload)
345
+ throw new Error(`multipart group manifest is missing for ${chunk.source_id}:${target.destination_id}`);
346
+ return this.signProviderRoute({
347
+ chunk,
348
+ target,
349
+ source,
350
+ destination,
351
+ expiresIn,
352
+ ...(upload ? { upload } : {}),
353
+ transferId: prepared.transfer_id,
354
+ finalObjectKey,
355
+ signedUrlFlow: requestedSignedUrlFlow,
356
+ partNumber: typeof metadata.part_number === "number"
357
+ ? metadata.part_number
358
+ : multipartPartNumber(chunk.source_chunk_index),
359
+ });
360
+ })();
361
+ pendingRoutes.add(pendingRoute);
362
+ if (pendingRoutes.size >= signingConcurrency)
363
+ await streamNextCompletedRoute();
254
364
  }
255
- signedInWindow = 0;
256
- signingWindowStartedAt = performance.now();
257
365
  }
258
- };
259
- for (const chunk of materializePlanChunks(prepared.plan_descriptor, prepared.transfer_id)) {
260
- await input.throwIfCancelled?.(prepared.transfer_id);
261
- for (const target of chunk.destinations) {
262
- const pendingRoute = (async () => {
263
- await input.throwIfCancelled?.(prepared.transfer_id);
264
- const destination = destinationsById.get(target.destination_id);
265
- if (!destination)
266
- throw new Error(`BeamCore returned unknown destination_id: ${target.destination_id}`);
267
- const source = sourcesById.get(chunk.source_id);
268
- if (!source)
269
- throw new Error(`BeamCore returned unknown source_id: ${chunk.source_id}`);
270
- const metadata = target.metadata ?? {};
271
- const finalObjectKey = typeof metadata.final_object_key === "string" ? metadata.final_object_key : target.object_key;
272
- if (!finalObjectKey)
273
- throw new Error("destination signing target is missing object_key");
274
- const multipartGroupId = multipartGroupStateKey(prepared.transfer_id, target.destination_id, chunk.source_id, finalObjectKey);
275
- const upload = isHippiusDestination(destination)
276
- ? undefined
277
- : await multipartGroupWaiters.get(multipartGroupId)?.promise;
278
- if (!isHippiusDestination(destination) && !upload)
279
- throw new Error(`multipart group manifest is missing for ${chunk.source_id}:${target.destination_id}`);
280
- return this.signProviderRoute({
281
- chunk,
282
- target,
283
- source,
284
- destination,
285
- expiresIn,
286
- ...(upload ? { upload } : {}),
366
+ while (pendingRoutes.size)
367
+ await streamNextCompletedRoute();
368
+ const multipartGroupManifest = await multipartManifestTask;
369
+ if (requestedSignedUrlFlow === "signed_url_v2") {
370
+ validateMultipartGroupManifest(multipartGroupManifest, prepared.transfer_id);
371
+ }
372
+ const attached = await routeStream.complete();
373
+ if (!attached.success) {
374
+ throw new Error(attached.error ?? attached.message ?? "route stream failed");
375
+ }
376
+ }
377
+ catch (error) {
378
+ if (routeStream)
379
+ await routeStream.abort().catch(() => undefined);
380
+ await Promise.allSettled(pendingRoutes);
381
+ if (multipartManifestTask)
382
+ await Promise.allSettled([multipartManifestTask]);
383
+ if (!recoveryReplay) {
384
+ if (foregroundCancelled || isRecoverableRouteStreamError(error)) {
385
+ this.control.continueRecoveryLease(prepared.transfer_id);
386
+ }
387
+ else {
388
+ this.control.releaseRecoveryLease(prepared.transfer_id);
389
+ await cancelAndAbortProviderFailure({
390
+ client: this,
287
391
  transferId: prepared.transfer_id,
288
- finalObjectKey,
289
- signedUrlFlow: requestedSignedUrlFlow,
290
- partNumber: typeof metadata.part_number === "number"
291
- ? metadata.part_number
292
- : multipartPartNumber(chunk.source_chunk_index),
392
+ cause: error,
393
+ uploads: multipartUploads,
394
+ abortBeforeCancel: !routeStreamBeginAttempted
293
395
  });
294
- })();
295
- pendingRoutes.add(pendingRoute);
296
- if (pendingRoutes.size >= signingConcurrency)
297
- await streamNextCompletedRoute();
396
+ }
298
397
  }
398
+ throw error;
399
+ }
400
+ };
401
+ this.control.registerRecoveryLease({
402
+ transferId: prepared.transfer_id,
403
+ planFingerprint: prepared.plan_fingerprint,
404
+ coordinateChecksum: prepared.coordinate_checksum,
405
+ replayRoutes: async (routeGenerationId) => {
406
+ await routeStreamLock.run(() => streamPreparedRoutes(routeGenerationId, true));
407
+ },
408
+ disposeSecrets: () => clearRecoverySecrets(retainedRecoveryInput)
409
+ });
410
+ try {
411
+ try {
412
+ await input.onPrepared?.(prepared);
413
+ }
414
+ catch (error) {
415
+ this.control.continueRecoveryLease(prepared.transfer_id);
416
+ throw error;
299
417
  }
300
- while (pendingRoutes.size)
301
- await streamNextCompletedRoute();
302
- const multipartGroupManifest = await multipartManifestTask;
303
- if (requestedSignedUrlFlow === "signed_url_v2") {
304
- validateMultipartGroupManifest(multipartGroupManifest, prepared.transfer_id);
418
+ try {
419
+ await input.throwIfCancelled?.(prepared.transfer_id);
305
420
  }
306
- const attached = await routeStream.complete();
307
- if (!attached.success) {
308
- throw new Error(attached.error ?? attached.message ?? "route stream failed");
421
+ catch (error) {
422
+ this.control.continueRecoveryLease(prepared.transfer_id);
423
+ throw error;
309
424
  }
425
+ await streamPreparedRoutes(prepared.route_generation_id, false);
426
+ initialThrowIfCancelled = undefined;
310
427
  }
311
- catch (error) {
312
- if (routeStream)
313
- await routeStream.abort();
314
- await Promise.allSettled(pendingRoutes);
315
- if (multipartManifestTask)
316
- await Promise.allSettled([multipartManifestTask]);
317
- await cancelAndAbortProviderFailure({
318
- client: this,
319
- transferId: prepared.transfer_id,
320
- cause: error,
321
- uploads: multipartUploads,
322
- abortBeforeCancel: !routeStreamBeginAttempted
323
- });
324
- throw error;
428
+ finally {
429
+ releaseInitialStream();
325
430
  }
326
431
  return prepared;
327
432
  }
@@ -486,6 +591,27 @@ export class BeamClient {
486
591
  }
487
592
  }
488
593
  const ROUTE_STREAM_BATCH_ROUTES = 2_048;
594
+ class AsyncMutex {
595
+ tail = Promise.resolve();
596
+ async acquire() {
597
+ const previous = this.tail;
598
+ let release;
599
+ this.tail = new Promise((resolve) => {
600
+ release = resolve;
601
+ });
602
+ await previous;
603
+ return release;
604
+ }
605
+ async run(operation) {
606
+ const release = await this.acquire();
607
+ try {
608
+ return await operation();
609
+ }
610
+ finally {
611
+ release();
612
+ }
613
+ }
614
+ }
489
615
  class RouteStreamSender {
490
616
  control;
491
617
  options;
@@ -505,6 +631,7 @@ class RouteStreamSender {
505
631
  async begin() {
506
632
  await this.control.request("transfer.route_stream.begin", compact({
507
633
  transfer_id: this.options.transferId,
634
+ route_generation_id: this.options.routeGenerationId,
508
635
  stream_id: this.streamId,
509
636
  total_routes: this.options.totalRoutes,
510
637
  total_chunks: this.options.totalChunks,
@@ -524,6 +651,7 @@ class RouteStreamSender {
524
651
  const identity = await stableManifestBatchIdentity([group]);
525
652
  await this.control.request("transfer.route_stream.manifest", {
526
653
  transfer_id: this.options.transferId,
654
+ route_generation_id: this.options.routeGenerationId,
527
655
  stream_id: this.streamId,
528
656
  manifest_batch_id: identity,
529
657
  groups: [group]
@@ -562,6 +690,7 @@ class RouteStreamSender {
562
690
  throw this.sendError;
563
691
  return this.control.request("transfer.route_stream.complete", {
564
692
  transfer_id: this.options.transferId,
693
+ route_generation_id: this.options.routeGenerationId,
565
694
  stream_id: this.streamId,
566
695
  expected_batches: this.batchIndex,
567
696
  expected_routes: this.options.totalRoutes,
@@ -580,6 +709,7 @@ class RouteStreamSender {
580
709
  const routes = this.batch.splice(0, this.batch.length);
581
710
  const chunks = this.control.splitRoutesForPayload("transfer.route_stream.batch", {
582
711
  transfer_id: this.options.transferId,
712
+ route_generation_id: this.options.routeGenerationId,
583
713
  stream_id: this.streamId,
584
714
  batch_id: `${this.streamId}:estimate`,
585
715
  batch_index: this.batchIndex
@@ -599,6 +729,7 @@ class RouteStreamSender {
599
729
  const batchId = `${this.streamId}:${batchIndex}:${coordinateChecksum}`;
600
730
  await this.control.request("transfer.route_stream.batch", {
601
731
  transfer_id: this.options.transferId,
732
+ route_generation_id: this.options.routeGenerationId,
602
733
  stream_id: this.streamId,
603
734
  batch_id: batchId,
604
735
  batch_index: batchIndex,
@@ -878,11 +1009,13 @@ async function createMultipartGroupManifest(input) {
878
1009
  "beam-transfer-id": input.prepared.transfer_id,
879
1010
  ...(input.signedUrlFlow === "signed_url_v2" ? { "beam-multipart-group-id": multipartGroupId } : {})
880
1011
  };
881
- const uploadId = await createMultipartUpload({
1012
+ const retainedUpload = input.multipartUploads.get(multipartGroupId);
1013
+ const uploadId = retainedUpload?.uploadId ?? await createMultipartUpload({
882
1014
  destination: destinationConfig,
883
1015
  objectKey: finalObjectKey,
884
1016
  metadata: finalObjectMetadata
885
1017
  });
1018
+ const createdUpload = !retainedUpload;
886
1019
  try {
887
1020
  const urlsExpiresAt = expiresAtIso(input.expiresIn);
888
1021
  const stagingObjectPrefix = buildStagingObjectPrefix({
@@ -940,11 +1073,13 @@ async function createMultipartGroupManifest(input) {
940
1073
  return { ok: true, state };
941
1074
  }
942
1075
  catch (error) {
943
- try {
944
- await abortMultipartUpload(destinationConfig, finalObjectKey, uploadId);
945
- }
946
- catch (abortError) {
947
- throw new AggregateError([error, abortError], `multipart group setup and cleanup failed for ${multipartGroupId}`);
1076
+ if (createdUpload) {
1077
+ try {
1078
+ await abortMultipartUpload(destinationConfig, finalObjectKey, uploadId);
1079
+ }
1080
+ catch (abortError) {
1081
+ throw new AggregateError([error, abortError], `multipart group setup and cleanup failed for ${multipartGroupId}`);
1082
+ }
948
1083
  }
949
1084
  throw error;
950
1085
  }
@@ -957,6 +1092,8 @@ async function createMultipartGroupManifest(input) {
957
1092
  });
958
1093
  const failure = results.find((result) => !result.ok);
959
1094
  if (failure && !failure.ok) {
1095
+ if (input.preserveUploadsOnFailure)
1096
+ throw failure.error;
960
1097
  try {
961
1098
  await abortCreatedUploads(input.multipartUploads);
962
1099
  }
@@ -1235,3 +1372,13 @@ function isHippiusDestination(destination) {
1235
1372
  function sleep(ms) {
1236
1373
  return new Promise((resolve) => setTimeout(resolve, ms));
1237
1374
  }
1375
+ function clearRecoverySecrets(input) {
1376
+ const secretKeys = new Set(["access_key_id", "secret_access_key", "session_token", "api_token", "token", "password"]);
1377
+ for (const provider of [...input.sources, ...input.destinations]) {
1378
+ const record = provider;
1379
+ for (const key of secretKeys) {
1380
+ if (key in record)
1381
+ record[key] = "";
1382
+ }
1383
+ }
1384
+ }