@beam-network/sdk 0.5.0 → 0.5.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.
package/dist/client.js CHANGED
@@ -1,10 +1,27 @@
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
+ export class BeamRouteRecoveryPendingError extends Error {
4
+ transferId;
5
+ cause;
6
+ constructor(transferId, cause) {
7
+ super(`Transfer ${transferId} is prepared and route recovery is continuing in the background.`);
8
+ this.name = "BeamRouteRecoveryPendingError";
9
+ this.transferId = transferId;
10
+ this.cause = cause;
11
+ }
12
+ }
3
13
  import { multipartPartNumber } from "./multipart-limits.js";
4
14
  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.
15
+ export class BeamApiError extends Error {
16
+ status;
17
+ body;
18
+ constructor(message, status, body) {
19
+ super(message);
20
+ this.status = status;
21
+ this.body = body;
22
+ this.name = "BeamApiError";
23
+ }
24
+ }
8
25
  export class BeamClient {
9
26
  apiKey;
10
27
  natsUrl;
@@ -12,10 +29,6 @@ export class BeamClient {
12
29
  control;
13
30
  routeSigningConcurrency;
14
31
  routeSigningConcurrencyOverridden;
15
- /** Last credit state Beam reported. Null until the first request or connect. */
16
- get creditStatus() {
17
- return this.control.creditStatus;
18
- }
19
32
  constructor(options = {}) {
20
33
  if (!options.apiKey?.trim()) {
21
34
  throw new Error("apiKey is required.");
@@ -32,8 +45,7 @@ export class BeamClient {
32
45
  subjectPrefix: options.transferClientSubjectPrefix,
33
46
  transferRuntimeShardCount: options.transferRuntimeShardCount,
34
47
  requestTimeoutMs: options.requestTimeoutMs,
35
- maxPayloadBytes: options.maxPayloadBytes,
36
- onCreditWarning: options.onCreditWarning
48
+ maxPayloadBytes: options.maxPayloadBytes
37
49
  });
38
50
  this.fetchImpl = options.fetch ?? globalThis.fetch;
39
51
  if (!this.fetchImpl) {
@@ -71,9 +83,12 @@ export class BeamClient {
71
83
  createTransfer(input) {
72
84
  return this.prepareProviderTransfer(input);
73
85
  }
74
- transferStatus(transferId) {
86
+ async transferStatus(transferId) {
75
87
  validateId(transferId, "transferId");
76
- return this.control.request("transfer.status", { transfer_id: transferId }, { transferId });
88
+ const status = await this.control.request("transfer.status", { transfer_id: transferId }, { transferId });
89
+ if (["completed", "failed", "cancelled"].includes(status.status))
90
+ this.control.releaseRecoveryLease(transferId);
91
+ return status;
77
92
  }
78
93
  distributeTransfer(transferId) {
79
94
  validateId(transferId, "transferId");
@@ -88,6 +103,7 @@ export class BeamClient {
88
103
  transferId,
89
104
  idempotencyKey: `transfer:${transferId}:cancel`
90
105
  });
106
+ this.control.releaseRecoveryLease(transferId);
91
107
  return result;
92
108
  }
93
109
  async planTransfer(input) {
@@ -105,8 +121,10 @@ export class BeamClient {
105
121
  }
106
122
  async prepareTransfer(input) {
107
123
  const transferId = await transferIdForIdempotencyKey(input.idempotencyKey);
124
+ const routeGenerationId = input.routeGenerationId ?? randomUuid();
108
125
  const result = await this.control.request("transfer.prepare", compact({
109
126
  transfer_id: transferId,
127
+ route_generation_id: routeGenerationId,
110
128
  sources: input.sources,
111
129
  destinations: input.destinations,
112
130
  name: input.name,
@@ -125,56 +143,98 @@ export class BeamClient {
125
143
  validateId(transferId, "transferId");
126
144
  validateMultipartGroupManifest(input.multipartGroupManifest, transferId);
127
145
  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,
146
+ const streamRoutes = async (chunkRoutes, multipartGroupManifest, routeGenerationId, urlsExpiresAt) => {
147
+ validateMultipartGroupManifest(multipartGroupManifest, transferId);
148
+ validateSignedRouteManifestContract(chunkRoutes, multipartGroupManifest);
149
+ const destinationCount = new Set(chunkRoutes.map((route) => route.destination_id)).size;
150
+ const orderedRoutes = chunkRoutes.map((route) => {
151
+ if (signedRouteDeliveryIndex(route) !== undefined)
152
+ return route;
153
+ if (destinationCount !== 1) {
154
+ throw new Error("delivery_index is required when manually attaching routes for multiple destinations");
155
+ }
156
+ return { ...route, delivery_index: route.chunk_index };
157
+ })
158
+ .sort(compareSignedRoutesByDelivery);
159
+ const streamId = await stableRouteStreamId({
160
+ transferId,
161
+ planIdentity: `${await routeCoordinateChecksum(orderedRoutes)}:${routeGenerationId}`,
162
+ totalRoutes: orderedRoutes.length,
163
+ totalChunks: countDistinctRouteChunks(orderedRoutes),
164
+ signedUrlFlow: multipartGroupManifest.length ? "signed_url_v2" : "signed_url_v1"
165
+ });
166
+ const stream = new RouteStreamSender(this.control, {
167
+ streamId,
168
+ transferId,
169
+ routeGenerationId,
170
+ totalRoutes: orderedRoutes.length,
171
+ totalChunks: countDistinctRouteChunks(orderedRoutes),
172
+ urlsExpiresAt,
173
+ signedUrlFlow: multipartGroupManifest.length ? "signed_url_v2" : "signed_url_v1",
174
+ autoDistribute: input.autoDistribute ?? true
175
+ });
176
+ await stream.begin();
177
+ if (multipartGroupManifest.length)
178
+ await stream.addManifestGroups(multipartGroupManifest);
179
+ for (const route of orderedRoutes)
180
+ await stream.addRoute(route);
181
+ return stream.complete();
182
+ };
183
+ const routeStreamLock = new AsyncMutex();
184
+ const releaseInitialStream = await routeStreamLock.acquire();
185
+ this.control.registerRecoveryLease({
148
186
  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
187
+ planFingerprint: input.planFingerprint,
188
+ coordinateChecksum: input.coordinateChecksum,
189
+ replayRoutes: async (routeGenerationId) => {
190
+ await routeStreamLock.run(async () => {
191
+ const fresh = await input.recoveryFactory(routeGenerationId);
192
+ const attached = await streamRoutes(fresh.chunkRoutes, fresh.multipartGroupManifest, routeGenerationId, fresh.urlsExpiresAt);
193
+ if (!attached.success)
194
+ throw new Error(attached.error ?? attached.message ?? "route replay failed");
195
+ });
196
+ }
154
197
  });
155
- await stream.begin();
156
- if (input.multipartGroupManifest.length) {
157
- await stream.addManifestGroups(input.multipartGroupManifest);
198
+ try {
199
+ const attached = await streamRoutes(input.chunkRoutes, input.multipartGroupManifest, input.routeGenerationId, input.urlsExpiresAt);
200
+ if (!attached.success)
201
+ this.control.releaseRecoveryLease(transferId);
202
+ return attached;
158
203
  }
159
- for (const route of orderedRoutes) {
160
- await stream.addRoute(route);
204
+ catch (error) {
205
+ if (isRecoverableRouteStreamError(error)) {
206
+ this.control.continueRecoveryLease(transferId);
207
+ throw new BeamRouteRecoveryPendingError(transferId, error);
208
+ }
209
+ this.control.releaseRecoveryLease(transferId);
210
+ throw error;
211
+ }
212
+ finally {
213
+ releaseInitialStream();
161
214
  }
162
- return stream.complete();
163
215
  }
164
216
  async prepareProviderTransfer(input) {
165
217
  const expiresIn = input.expiresIn ?? 3600;
166
218
  const requestedSignedUrlFlow = input.signedUrlFlow ?? "signed_url_v1";
167
- const preparedDestinations = input.destinations.map((destination, index) => prepareProviderDestination(destination, { index }));
219
+ const retainedRecoveryInput = {
220
+ ...input,
221
+ sources: input.sources.map((source) => ({ ...source })),
222
+ destinations: input.destinations.map((destination) => ({ ...destination })),
223
+ onBeforeTransferPrepare: undefined,
224
+ onPrepared: undefined,
225
+ throwIfCancelled: undefined
226
+ };
227
+ const preparedDestinations = retainedRecoveryInput.destinations.map((destination, index) => prepareProviderDestination(destination, { index }));
168
228
  const destinationsById = new Map();
169
229
  preparedDestinations.forEach((preparedDestination, index) => {
170
- const destination = input.destinations[index];
230
+ const destination = retainedRecoveryInput.destinations[index];
171
231
  if (!destination) {
172
232
  throw new Error(`missing provider destination config for prepared destination ${preparedDestination.destination_id}`);
173
233
  }
174
234
  destinationsById.set(preparedDestination.destination_id, destination);
175
235
  });
176
236
  await input.throwIfCancelled?.();
177
- const preparedSources = await Promise.all(input.sources.map((source, index) => prepareProviderSource(source, { index, expiresIn, fetchImpl: this.fetchImpl })));
237
+ const preparedSources = await Promise.all(retainedRecoveryInput.sources.map((source, index) => prepareProviderSource(source, { index, expiresIn, fetchImpl: this.fetchImpl })));
178
238
  await input.throwIfCancelled?.();
179
239
  await input.onBeforeTransferPrepare?.();
180
240
  const prepared = await this.prepareTransfer({
@@ -183,145 +243,205 @@ export class BeamClient {
183
243
  name: input.name,
184
244
  testMode: input.testMode,
185
245
  signedUrlFlow: requestedSignedUrlFlow,
186
- idempotencyKey: input.idempotencyKey
246
+ idempotencyKey: input.idempotencyKey,
247
+ routeGenerationId: input.routeGenerationId
187
248
  });
188
249
  if (!prepared.success) {
189
250
  return prepared;
190
251
  }
191
- await input.onPrepared?.(prepared);
192
- await input.throwIfCancelled?.(prepared.transfer_id);
193
252
  const sourcesById = new Map();
194
253
  preparedSources.forEach((preparedSource, index) => {
195
- const source = input.sources[index];
254
+ const source = retainedRecoveryInput.sources[index];
196
255
  if (!source) {
197
256
  throw new Error(`missing provider source config for prepared source ${preparedSource.source_id}`);
198
257
  }
199
258
  sourcesById.set(preparedSource.source_id, source);
200
259
  });
201
260
  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]);
261
+ const autoDistribute = input.distribute !== false;
262
+ let initialThrowIfCancelled = input.throwIfCancelled;
263
+ const routeStreamLock = new AsyncMutex();
264
+ const releaseInitialStream = await routeStreamLock.acquire();
265
+ const streamPreparedRoutes = async (routeGenerationId, recoveryReplay) => {
266
+ const pendingRoutes = new Set();
267
+ let signingConcurrency = this.routeSigningConcurrency;
268
+ let signedInWindow = 0;
269
+ let signingWindowStartedAt = performance.now();
270
+ let routeStream = null;
271
+ let routeStreamBeginAttempted = false;
272
+ let multipartManifestTask = null;
273
+ let multipartGroupWaiters = new Map();
274
+ let foregroundCancelled = false;
275
+ const throwIfCancelled = recoveryReplay || !initialThrowIfCancelled ? undefined : async (transferId) => {
276
+ try {
277
+ await initialThrowIfCancelled?.(transferId);
278
+ }
279
+ catch (error) {
280
+ foregroundCancelled = true;
281
+ throw error;
282
+ }
283
+ };
284
+ try {
285
+ const streamId = await stableRouteStreamId({
286
+ transferId: prepared.transfer_id,
287
+ planIdentity: `${prepared.plan_descriptor.plan_nonce}:${routeGenerationId}`,
288
+ totalRoutes: prepared.plan_descriptor.delivery_route_count,
289
+ totalChunks: prepared.plan_descriptor.logical_chunk_count,
290
+ signedUrlFlow: requestedSignedUrlFlow
291
+ });
292
+ routeStream = new RouteStreamSender(this.control, {
293
+ streamId,
294
+ transferId: prepared.transfer_id,
295
+ routeGenerationId,
296
+ totalRoutes: prepared.plan_descriptor.delivery_route_count,
297
+ totalChunks: prepared.plan_descriptor.logical_chunk_count,
298
+ signedUrlFlow: requestedSignedUrlFlow,
299
+ autoDistribute,
300
+ urlsExpiresAt: expiresAtIso(expiresIn)
301
+ });
302
+ routeStreamBeginAttempted = true;
303
+ await routeStream.begin();
304
+ multipartGroupWaiters = createMultipartGroupWaiters(prepared, destinationsById);
305
+ multipartManifestTask = createMultipartGroupManifest({
306
+ prepared,
307
+ destinationsById,
308
+ multipartUploads,
309
+ expiresIn,
310
+ signedUrlFlow: requestedSignedUrlFlow,
311
+ concurrency: this.routeSigningConcurrency,
312
+ preserveUploadsOnFailure: recoveryReplay,
313
+ onGroupReady: async (state) => {
314
+ if (requestedSignedUrlFlow === "signed_url_v2") {
315
+ validateMultipartGroupManifest([state.manifest], prepared.transfer_id);
316
+ await routeStream.addManifestGroups([state.manifest]);
317
+ }
318
+ multipartGroupWaiters.get(state.manifest.multipart_group_id)?.resolve(state);
319
+ },
320
+ onGroupFailed: (groupId, error) => multipartGroupWaiters.get(groupId)?.reject(error)
321
+ });
322
+ const streamNextCompletedRoute = async () => {
323
+ const settled = await Promise.race([...pendingRoutes].map((pending) => pending.then((route) => ({ pending, route }))));
324
+ pendingRoutes.delete(settled.pending);
325
+ await routeStream.addRoute(settled.route);
326
+ signedInWindow += 1;
327
+ if (signedInWindow === ROUTE_STREAM_BATCH_ROUTES) {
328
+ const elapsedMs = performance.now() - signingWindowStartedAt;
329
+ if (!this.routeSigningConcurrencyOverridden && elapsedMs > 4_000 && signingConcurrency < 256) {
330
+ signingConcurrency = Math.min(256, signingConcurrency * 2);
331
+ }
332
+ signedInWindow = 0;
333
+ signingWindowStartedAt = performance.now();
240
334
  }
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);
335
+ };
336
+ for (const chunk of materializePlanChunks(prepared.plan_descriptor, prepared.transfer_id)) {
337
+ await throwIfCancelled?.(prepared.transfer_id);
338
+ for (const target of chunk.destinations) {
339
+ const pendingRoute = (async () => {
340
+ await throwIfCancelled?.(prepared.transfer_id);
341
+ const destination = destinationsById.get(target.destination_id);
342
+ if (!destination)
343
+ throw new Error(`BeamCore returned unknown destination_id: ${target.destination_id}`);
344
+ const source = sourcesById.get(chunk.source_id);
345
+ if (!source)
346
+ throw new Error(`BeamCore returned unknown source_id: ${chunk.source_id}`);
347
+ const metadata = target.metadata ?? {};
348
+ const finalObjectKey = typeof metadata.final_object_key === "string" ? metadata.final_object_key : target.object_key;
349
+ if (!finalObjectKey)
350
+ throw new Error("destination signing target is missing object_key");
351
+ const multipartGroupId = multipartGroupStateKey(prepared.transfer_id, target.destination_id, chunk.source_id, finalObjectKey);
352
+ const upload = isHippiusDestination(destination)
353
+ ? undefined
354
+ : await multipartGroupWaiters.get(multipartGroupId)?.promise;
355
+ if (!isHippiusDestination(destination) && !upload)
356
+ throw new Error(`multipart group manifest is missing for ${chunk.source_id}:${target.destination_id}`);
357
+ return this.signProviderRoute({
358
+ chunk,
359
+ target,
360
+ source,
361
+ destination,
362
+ expiresIn,
363
+ ...(upload ? { upload } : {}),
364
+ transferId: prepared.transfer_id,
365
+ finalObjectKey,
366
+ signedUrlFlow: requestedSignedUrlFlow,
367
+ partNumber: typeof metadata.part_number === "number"
368
+ ? metadata.part_number
369
+ : multipartPartNumber(chunk.source_chunk_index),
370
+ });
371
+ })();
372
+ pendingRoutes.add(pendingRoute);
373
+ if (pendingRoutes.size >= signingConcurrency)
374
+ await streamNextCompletedRoute();
254
375
  }
255
- signedInWindow = 0;
256
- signingWindowStartedAt = performance.now();
257
376
  }
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 } : {}),
377
+ while (pendingRoutes.size)
378
+ await streamNextCompletedRoute();
379
+ const multipartGroupManifest = await multipartManifestTask;
380
+ if (requestedSignedUrlFlow === "signed_url_v2") {
381
+ validateMultipartGroupManifest(multipartGroupManifest, prepared.transfer_id);
382
+ }
383
+ const attached = await routeStream.complete();
384
+ if (!attached.success) {
385
+ throw new Error(attached.error ?? attached.message ?? "route stream failed");
386
+ }
387
+ }
388
+ catch (error) {
389
+ if (routeStream)
390
+ await routeStream.abort().catch(() => undefined);
391
+ await Promise.allSettled(pendingRoutes);
392
+ if (multipartManifestTask)
393
+ await Promise.allSettled([multipartManifestTask]);
394
+ if (!recoveryReplay) {
395
+ if (foregroundCancelled) {
396
+ this.control.continueRecoveryLease(prepared.transfer_id);
397
+ }
398
+ else if (isRecoverableRouteStreamError(error)) {
399
+ this.control.continueRecoveryLease(prepared.transfer_id);
400
+ throw new BeamRouteRecoveryPendingError(prepared.transfer_id, error);
401
+ }
402
+ else {
403
+ this.control.releaseRecoveryLease(prepared.transfer_id);
404
+ await cancelAndAbortProviderFailure({
405
+ client: this,
287
406
  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),
407
+ cause: error,
408
+ uploads: multipartUploads,
409
+ abortBeforeCancel: !routeStreamBeginAttempted
293
410
  });
294
- })();
295
- pendingRoutes.add(pendingRoute);
296
- if (pendingRoutes.size >= signingConcurrency)
297
- await streamNextCompletedRoute();
411
+ }
298
412
  }
413
+ throw error;
299
414
  }
300
- while (pendingRoutes.size)
301
- await streamNextCompletedRoute();
302
- const multipartGroupManifest = await multipartManifestTask;
303
- if (requestedSignedUrlFlow === "signed_url_v2") {
304
- validateMultipartGroupManifest(multipartGroupManifest, prepared.transfer_id);
415
+ };
416
+ this.control.registerRecoveryLease({
417
+ transferId: prepared.transfer_id,
418
+ planFingerprint: prepared.plan_fingerprint,
419
+ coordinateChecksum: prepared.coordinate_checksum,
420
+ replayRoutes: async (routeGenerationId) => {
421
+ await routeStreamLock.run(() => streamPreparedRoutes(routeGenerationId, true));
422
+ },
423
+ disposeSecrets: () => clearRecoverySecrets(retainedRecoveryInput)
424
+ });
425
+ try {
426
+ try {
427
+ await input.onPrepared?.(prepared);
428
+ }
429
+ catch (error) {
430
+ this.control.continueRecoveryLease(prepared.transfer_id);
431
+ throw error;
305
432
  }
306
- const attached = await routeStream.complete();
307
- if (!attached.success) {
308
- throw new Error(attached.error ?? attached.message ?? "route stream failed");
433
+ try {
434
+ await input.throwIfCancelled?.(prepared.transfer_id);
435
+ }
436
+ catch (error) {
437
+ this.control.continueRecoveryLease(prepared.transfer_id);
438
+ throw error;
309
439
  }
440
+ await streamPreparedRoutes(prepared.route_generation_id, false);
441
+ initialThrowIfCancelled = undefined;
310
442
  }
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;
443
+ finally {
444
+ releaseInitialStream();
325
445
  }
326
446
  return prepared;
327
447
  }
@@ -486,6 +606,27 @@ export class BeamClient {
486
606
  }
487
607
  }
488
608
  const ROUTE_STREAM_BATCH_ROUTES = 2_048;
609
+ class AsyncMutex {
610
+ tail = Promise.resolve();
611
+ async acquire() {
612
+ const previous = this.tail;
613
+ let release;
614
+ this.tail = new Promise((resolve) => {
615
+ release = resolve;
616
+ });
617
+ await previous;
618
+ return release;
619
+ }
620
+ async run(operation) {
621
+ const release = await this.acquire();
622
+ try {
623
+ return await operation();
624
+ }
625
+ finally {
626
+ release();
627
+ }
628
+ }
629
+ }
489
630
  class RouteStreamSender {
490
631
  control;
491
632
  options;
@@ -505,6 +646,7 @@ class RouteStreamSender {
505
646
  async begin() {
506
647
  await this.control.request("transfer.route_stream.begin", compact({
507
648
  transfer_id: this.options.transferId,
649
+ route_generation_id: this.options.routeGenerationId,
508
650
  stream_id: this.streamId,
509
651
  total_routes: this.options.totalRoutes,
510
652
  total_chunks: this.options.totalChunks,
@@ -524,6 +666,7 @@ class RouteStreamSender {
524
666
  const identity = await stableManifestBatchIdentity([group]);
525
667
  await this.control.request("transfer.route_stream.manifest", {
526
668
  transfer_id: this.options.transferId,
669
+ route_generation_id: this.options.routeGenerationId,
527
670
  stream_id: this.streamId,
528
671
  manifest_batch_id: identity,
529
672
  groups: [group]
@@ -562,6 +705,7 @@ class RouteStreamSender {
562
705
  throw this.sendError;
563
706
  return this.control.request("transfer.route_stream.complete", {
564
707
  transfer_id: this.options.transferId,
708
+ route_generation_id: this.options.routeGenerationId,
565
709
  stream_id: this.streamId,
566
710
  expected_batches: this.batchIndex,
567
711
  expected_routes: this.options.totalRoutes,
@@ -580,6 +724,7 @@ class RouteStreamSender {
580
724
  const routes = this.batch.splice(0, this.batch.length);
581
725
  const chunks = this.control.splitRoutesForPayload("transfer.route_stream.batch", {
582
726
  transfer_id: this.options.transferId,
727
+ route_generation_id: this.options.routeGenerationId,
583
728
  stream_id: this.streamId,
584
729
  batch_id: `${this.streamId}:estimate`,
585
730
  batch_index: this.batchIndex
@@ -599,6 +744,7 @@ class RouteStreamSender {
599
744
  const batchId = `${this.streamId}:${batchIndex}:${coordinateChecksum}`;
600
745
  await this.control.request("transfer.route_stream.batch", {
601
746
  transfer_id: this.options.transferId,
747
+ route_generation_id: this.options.routeGenerationId,
602
748
  stream_id: this.streamId,
603
749
  batch_id: batchId,
604
750
  batch_index: batchIndex,
@@ -878,11 +1024,13 @@ async function createMultipartGroupManifest(input) {
878
1024
  "beam-transfer-id": input.prepared.transfer_id,
879
1025
  ...(input.signedUrlFlow === "signed_url_v2" ? { "beam-multipart-group-id": multipartGroupId } : {})
880
1026
  };
881
- const uploadId = await createMultipartUpload({
1027
+ const retainedUpload = input.multipartUploads.get(multipartGroupId);
1028
+ const uploadId = retainedUpload?.uploadId ?? await createMultipartUpload({
882
1029
  destination: destinationConfig,
883
1030
  objectKey: finalObjectKey,
884
1031
  metadata: finalObjectMetadata
885
1032
  });
1033
+ const createdUpload = !retainedUpload;
886
1034
  try {
887
1035
  const urlsExpiresAt = expiresAtIso(input.expiresIn);
888
1036
  const stagingObjectPrefix = buildStagingObjectPrefix({
@@ -940,11 +1088,13 @@ async function createMultipartGroupManifest(input) {
940
1088
  return { ok: true, state };
941
1089
  }
942
1090
  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}`);
1091
+ if (createdUpload) {
1092
+ try {
1093
+ await abortMultipartUpload(destinationConfig, finalObjectKey, uploadId);
1094
+ }
1095
+ catch (abortError) {
1096
+ throw new AggregateError([error, abortError], `multipart group setup and cleanup failed for ${multipartGroupId}`);
1097
+ }
948
1098
  }
949
1099
  throw error;
950
1100
  }
@@ -957,6 +1107,8 @@ async function createMultipartGroupManifest(input) {
957
1107
  });
958
1108
  const failure = results.find((result) => !result.ok);
959
1109
  if (failure && !failure.ok) {
1110
+ if (input.preserveUploadsOnFailure)
1111
+ throw failure.error;
960
1112
  try {
961
1113
  await abortCreatedUploads(input.multipartUploads);
962
1114
  }
@@ -1235,3 +1387,13 @@ function isHippiusDestination(destination) {
1235
1387
  function sleep(ms) {
1236
1388
  return new Promise((resolve) => setTimeout(resolve, ms));
1237
1389
  }
1390
+ function clearRecoverySecrets(input) {
1391
+ const secretKeys = new Set(["access_key_id", "secret_access_key", "session_token", "api_token", "token", "password"]);
1392
+ for (const provider of [...input.sources, ...input.destinations]) {
1393
+ const record = provider;
1394
+ for (const key of secretKeys) {
1395
+ if (key in record)
1396
+ record[key] = "";
1397
+ }
1398
+ }
1399
+ }