@beam-network/sdk 0.2.23 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -1,6 +1,6 @@
1
- import { abortMultipartUpload, createMultipartUpload, nowIso, prepareProviderDestination, prepareProviderSource, listPartsPageMarker, listPartsVerifyMarker, signAbortMultipartUpload, signCompleteMultipartUpload, signDeleteObject, signDestinationRoute, signListMultipartUpload, signListObjects, signUploadPartCopy } from "./provider-signing.js";
1
+ import { abortMultipartUpload, createMultipartUpload, expiresAtIso, prepareProviderDestination, prepareProviderSource, signAbortMultipartUpload, signCompleteMultipartUpload, signDeleteObject, signDestinationRoute, signFinalObjectHead, signListMultipartUpload, signListObjects, signUploadPartCopy } from "./provider-signing.js";
2
2
  import { BeamTransferControl, BEAM_DEFAULT_NATS_URL, compactSignedRoutes } from "./nats-control.js";
3
- import { directMultipartInitialPartNumber } from "./multipart-limits.js";
3
+ import { multipartPartNumber } from "./multipart-limits.js";
4
4
  export { BEAM_DEFAULT_NATS_URL };
5
5
  export class BeamApiError extends Error {
6
6
  status;
@@ -18,7 +18,7 @@ export class BeamClient {
18
18
  fetchImpl;
19
19
  control;
20
20
  routeSigningConcurrency;
21
- recoverySigners = new Map();
21
+ routeSigningConcurrencyOverridden;
22
22
  constructor(options = {}) {
23
23
  if (!options.apiKey?.trim()) {
24
24
  throw new Error("apiKey is required.");
@@ -26,6 +26,7 @@ export class BeamClient {
26
26
  this.apiKey = options.apiKey;
27
27
  this.natsUrl = options.natsUrl ?? options.natsWsUrl ?? BEAM_DEFAULT_NATS_URL;
28
28
  this.routeSigningConcurrency = positiveInteger(options.routeSigningConcurrency ?? 64, "routeSigningConcurrency");
29
+ this.routeSigningConcurrencyOverridden = options.routeSigningConcurrency !== undefined;
29
30
  this.control = new BeamTransferControl({
30
31
  apiKey: this.apiKey,
31
32
  natsUrl: options.natsUrl,
@@ -42,12 +43,16 @@ export class BeamClient {
42
43
  }
43
44
  }
44
45
  async close() {
45
- this.stopAllRecoverySigners();
46
46
  return this.control.close();
47
47
  }
48
- createRawTransfer(input) {
48
+ openTransferTerminalWaiter(transferId) {
49
+ validateId(transferId, "transferId");
50
+ return this.control.openTerminalSignalWaiter(transferId);
51
+ }
52
+ async createRawTransfer(input) {
53
+ const transferId = await transferIdForIdempotencyKey(input.idempotencyKey);
49
54
  const body = compact({
50
- transfer_id: newTransferId(),
55
+ transfer_id: transferId,
51
56
  sources: input.sources,
52
57
  destinations: input.destinations,
53
58
  total_size: input.totalSize,
@@ -58,71 +63,107 @@ export class BeamClient {
58
63
  callbacks: input.callbacks,
59
64
  test_mode: input.testMode || undefined,
60
65
  progressive_mode: input.progressiveMode || undefined,
61
- signed_url_flow: input.signedUrlFlow
66
+ signed_url_flow: "signed_url_v2"
67
+ });
68
+ return this.control.request("transfer.create", body, {
69
+ transferId,
70
+ idempotencyKey: `transfer:${transferId}:create`
62
71
  });
63
- return this.control.request("transfer.create", body);
64
72
  }
65
73
  createTransfer(input) {
66
74
  return this.prepareProviderTransfer(input);
67
75
  }
68
76
  transferStatus(transferId) {
69
77
  validateId(transferId, "transferId");
70
- return this.control.request("transfer.status", { transfer_id: transferId }, transferId);
78
+ return this.control.request("transfer.status", { transfer_id: transferId }, { transferId });
71
79
  }
72
80
  distributeTransfer(transferId) {
73
81
  validateId(transferId, "transferId");
74
- return this.control.request("transfer.distribute", { transfer_id: transferId }, transferId);
82
+ return this.control.request("transfer.distribute", { transfer_id: transferId }, {
83
+ transferId,
84
+ idempotencyKey: `transfer:${transferId}:distribute`
85
+ });
75
86
  }
76
87
  async cancelTransfer(transferId) {
77
88
  validateId(transferId, "transferId");
78
- try {
79
- return await this.control.request("transfer.cancel", { transfer_id: transferId }, transferId);
80
- }
81
- finally {
82
- this.stopRecoverySigner(transferId);
83
- }
89
+ const result = await this.control.request("transfer.cancel", { transfer_id: transferId }, {
90
+ transferId,
91
+ idempotencyKey: `transfer:${transferId}:cancel`
92
+ });
93
+ return result;
84
94
  }
85
- planTransfer(input) {
86
- return this.control.request("transfer.plan", compact({
95
+ async planTransfer(input) {
96
+ const result = await this.control.request("transfer.plan", compact({
87
97
  sources: input.sources,
88
98
  destinations: input.destinations,
89
99
  name: input.name,
90
100
  test_mode: input.testMode || undefined,
91
101
  urls_expires_at: input.urlsExpiresAt,
92
- signed_url_flow: input.signedUrlFlow
102
+ signed_url_flow: "signed_url_v2"
93
103
  }));
104
+ if (result.success)
105
+ validateCompactTransferPlan(result.plan_descriptor, result.signed_url_flow);
106
+ return result;
94
107
  }
95
- prepareTransfer(input) {
96
- const transferId = newTransferId();
97
- return this.control.request("transfer.prepare", compact({
108
+ async prepareTransfer(input) {
109
+ const transferId = await transferIdForIdempotencyKey(input.idempotencyKey);
110
+ const result = await this.control.request("transfer.prepare", compact({
98
111
  transfer_id: transferId,
99
112
  sources: input.sources,
100
113
  destinations: input.destinations,
101
114
  name: input.name,
102
115
  test_mode: input.testMode || undefined,
103
116
  urls_expires_at: input.urlsExpiresAt,
104
- signed_url_flow: input.signedUrlFlow
105
- }), transferId);
117
+ signed_url_flow: "signed_url_v2"
118
+ }), {
119
+ transferId,
120
+ idempotencyKey: `transfer:${transferId}:prepare`
121
+ });
122
+ if (result.success)
123
+ validateCompactTransferPlan(result.plan_descriptor, result.signed_url_flow);
124
+ return result;
106
125
  }
107
126
  async attachSignedUrls(transferId, input) {
108
127
  validateId(transferId, "transferId");
128
+ validateMultipartGroupManifest(input.multipartGroupManifest, transferId);
129
+ validateSignedRouteManifestContract(input.chunkRoutes, input.multipartGroupManifest);
130
+ const destinationCount = new Set(input.chunkRoutes.map((route) => route.destination_id)).size;
131
+ const orderedRoutes = input.chunkRoutes
132
+ .map((route) => {
133
+ if (signedRouteDeliveryIndex(route) !== undefined)
134
+ return route;
135
+ if (destinationCount !== 1) {
136
+ throw new Error("delivery_index is required when manually attaching routes for multiple destinations");
137
+ }
138
+ return { ...route, delivery_index: route.chunk_index };
139
+ })
140
+ .sort(compareSignedRoutesByDelivery);
141
+ const streamId = await stableRouteStreamId({
142
+ transferId,
143
+ planIdentity: await routeCoordinateChecksum(orderedRoutes),
144
+ totalRoutes: orderedRoutes.length,
145
+ totalChunks: countDistinctRouteChunks(orderedRoutes)
146
+ });
109
147
  const stream = new RouteStreamSender(this.control, {
148
+ streamId,
110
149
  transferId,
111
- totalRoutes: input.chunkRoutes.length,
112
- totalChunks: countDistinctRouteChunks(input.chunkRoutes),
150
+ totalRoutes: orderedRoutes.length,
151
+ totalChunks: countDistinctRouteChunks(orderedRoutes),
113
152
  urlsExpiresAt: input.urlsExpiresAt,
114
- signedUrlFlow: input.signedUrlFlow ?? "signed_url_v1",
115
153
  autoDistribute: input.autoDistribute ?? true
116
154
  });
117
155
  await stream.begin();
118
- for (const route of input.chunkRoutes) {
119
- stream.addRoute(route);
156
+ if (input.multipartGroupManifest.length) {
157
+ await stream.addManifestGroups(input.multipartGroupManifest);
158
+ }
159
+ for (const route of orderedRoutes) {
160
+ await stream.addRoute(route);
120
161
  }
121
162
  return stream.complete();
122
163
  }
123
164
  async prepareProviderTransfer(input) {
124
165
  const expiresIn = input.expiresIn ?? 3600;
125
- const requestedSignedUrlFlow = input.signedUrlFlow ?? "signed_url_v1";
166
+ const requestedSignedUrlFlow = "signed_url_v2";
126
167
  const preparedDestinations = input.destinations.map((destination, index) => prepareProviderDestination(destination, { index }));
127
168
  const destinationsById = new Map();
128
169
  preparedDestinations.forEach((preparedDestination, index) => {
@@ -141,7 +182,8 @@ export class BeamClient {
141
182
  destinations: preparedDestinations,
142
183
  name: input.name,
143
184
  testMode: input.testMode,
144
- signedUrlFlow: requestedSignedUrlFlow
185
+ signedUrlFlow: requestedSignedUrlFlow,
186
+ idempotencyKey: input.idempotencyKey
145
187
  });
146
188
  if (!prepared.success) {
147
189
  return prepared;
@@ -156,38 +198,63 @@ export class BeamClient {
156
198
  }
157
199
  sourcesById.set(preparedSource.source_id, source);
158
200
  });
159
- const signedUrlFlow = prepared.signed_url_flow ?? requestedSignedUrlFlow;
160
- const routeStream = new RouteStreamSender(this.control, {
161
- transferId: prepared.transfer_id,
162
- totalRoutes: prepared.plan_descriptor.delivery_route_count,
163
- totalChunks: prepared.plan_descriptor.logical_chunk_count,
164
- signedUrlFlow,
165
- autoDistribute: input.distribute !== false
166
- });
167
201
  const multipartUploads = new Map();
168
- const multipartUploadPromises = new Map();
169
- await this.startProviderRouteRecoverySigner({
170
- prepared,
171
- sourcesById,
172
- destinationsById,
173
- multipartUploads,
174
- expiresIn,
175
- signedUrlFlow
176
- });
177
- let routeStreamStarted = false;
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();
178
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
+ });
217
+ routeStream = new RouteStreamSender(this.control, {
218
+ streamId,
219
+ transferId: prepared.transfer_id,
220
+ totalRoutes: prepared.plan_descriptor.delivery_route_count,
221
+ totalChunks: prepared.plan_descriptor.logical_chunk_count,
222
+ autoDistribute: input.distribute !== false
223
+ });
224
+ routeStreamBeginAttempted = true;
179
225
  await routeStream.begin();
180
- routeStreamStarted = true;
181
- const pendingRoutes = [];
182
- const flushOldestRoute = async () => {
183
- const pending = pendingRoutes.shift();
184
- if (pending)
185
- routeStream.addRoute(await pending);
226
+ multipartGroupWaiters = createMultipartGroupWaiters(prepared, destinationsById);
227
+ multipartManifestTask = createMultipartGroupManifest({
228
+ prepared,
229
+ destinationsById,
230
+ multipartUploads,
231
+ expiresIn,
232
+ concurrency: this.routeSigningConcurrency,
233
+ onGroupReady: async (state) => {
234
+ validateMultipartGroupManifest([state.manifest], prepared.transfer_id);
235
+ await routeStream.addManifestGroups([state.manifest]);
236
+ multipartGroupWaiters.get(state.manifest.multipart_group_id)?.resolve(state);
237
+ },
238
+ onGroupFailed: (groupId, error) => multipartGroupWaiters.get(groupId)?.reject(error)
239
+ });
240
+ const streamNextCompletedRoute = async () => {
241
+ const settled = await Promise.race([...pendingRoutes].map((pending) => pending.then((route) => ({ pending, route }))));
242
+ pendingRoutes.delete(settled.pending);
243
+ await routeStream.addRoute(settled.route);
244
+ signedInWindow += 1;
245
+ if (signedInWindow === ROUTE_STREAM_BATCH_ROUTES) {
246
+ const elapsedMs = performance.now() - signingWindowStartedAt;
247
+ if (!this.routeSigningConcurrencyOverridden && elapsedMs > 4_000 && signingConcurrency < 256) {
248
+ signingConcurrency = Math.min(256, signingConcurrency * 2);
249
+ }
250
+ signedInWindow = 0;
251
+ signingWindowStartedAt = performance.now();
252
+ }
186
253
  };
187
- for (const chunk of materializePlanChunks(prepared.plan_descriptor)) {
254
+ for (const chunk of materializePlanChunks(prepared.plan_descriptor, prepared.transfer_id)) {
188
255
  await input.throwIfCancelled?.(prepared.transfer_id);
189
256
  for (const target of chunk.destinations) {
190
- pendingRoutes.push((async () => {
257
+ const pendingRoute = (async () => {
191
258
  await input.throwIfCancelled?.(prepared.transfer_id);
192
259
  const destination = destinationsById.get(target.destination_id);
193
260
  if (!destination)
@@ -199,74 +266,53 @@ export class BeamClient {
199
266
  const finalObjectKey = typeof metadata.final_object_key === "string" ? metadata.final_object_key : target.object_key;
200
267
  if (!finalObjectKey)
201
268
  throw new Error("destination signing target is missing object_key");
202
- const multipartKey = `${prepared.transfer_id}:${target.destination_id}:${chunk.source_id}:${finalObjectKey}`;
203
- let uploadPromise = multipartUploadPromises.get(multipartKey);
204
- if (!uploadPromise) {
205
- uploadPromise = (async () => {
206
- const multipartCreatedAt = nowIso();
207
- const upload = isHippiusDestination(destination)
208
- ? {
209
- destination,
210
- objectKey: finalObjectKey,
211
- uploadId: "",
212
- completeUrl: "",
213
- abortUrl: "",
214
- listUrl: "",
215
- multipartGroupId: multipartKey,
216
- multipartCreatedAt
217
- }
218
- : await (async () => {
219
- const uploadId = await createMultipartUpload(destination, finalObjectKey);
220
- return {
221
- destination,
222
- objectKey: finalObjectKey,
223
- uploadId,
224
- completeUrl: await signCompleteMultipartUpload(destination, finalObjectKey, uploadId, expiresIn),
225
- abortUrl: await signAbortMultipartUpload(destination, finalObjectKey, uploadId, expiresIn),
226
- listUrl: await signListMultipartUpload(destination, finalObjectKey, uploadId, expiresIn),
227
- multipartGroupId: multipartKey,
228
- multipartCreatedAt
229
- };
230
- })();
231
- multipartUploads.set(multipartKey, upload);
232
- return upload;
233
- })();
234
- multipartUploadPromises.set(multipartKey, uploadPromise);
235
- }
236
- const upload = await uploadPromise;
269
+ const multipartGroupId = multipartGroupStateKey(prepared.transfer_id, target.destination_id, chunk.source_id, finalObjectKey);
270
+ const upload = isHippiusDestination(destination)
271
+ ? undefined
272
+ : await multipartGroupWaiters.get(multipartGroupId)?.promise;
273
+ if (!isHippiusDestination(destination) && !upload)
274
+ throw new Error(`multipart group manifest is missing for ${chunk.source_id}:${target.destination_id}`);
237
275
  return this.signProviderRoute({
238
276
  chunk,
239
277
  target,
240
278
  source,
241
279
  destination,
242
280
  expiresIn,
243
- upload,
281
+ ...(upload ? { upload } : {}),
244
282
  transferId: prepared.transfer_id,
245
283
  finalObjectKey,
246
284
  partNumber: typeof metadata.part_number === "number"
247
285
  ? metadata.part_number
248
- : directMultipartInitialPartNumber(chunk.chunk_index),
249
- signedUrlFlow
286
+ : multipartPartNumber(chunk.source_chunk_index),
250
287
  });
251
- })());
252
- if (pendingRoutes.length >= this.routeSigningConcurrency)
253
- await flushOldestRoute();
288
+ })();
289
+ pendingRoutes.add(pendingRoute);
290
+ if (pendingRoutes.size >= signingConcurrency)
291
+ await streamNextCompletedRoute();
254
292
  }
255
293
  }
256
- while (pendingRoutes.length)
257
- await flushOldestRoute();
294
+ while (pendingRoutes.size)
295
+ await streamNextCompletedRoute();
296
+ const multipartGroupManifest = await multipartManifestTask;
297
+ validateMultipartGroupManifest(multipartGroupManifest, prepared.transfer_id);
258
298
  const attached = await routeStream.complete();
259
299
  if (!attached.success) {
260
300
  throw new Error(attached.error ?? attached.message ?? "route stream failed");
261
301
  }
262
302
  }
263
303
  catch (error) {
264
- routeStream.abort();
265
- this.stopRecoverySigner(prepared.transfer_id);
266
- await abortCreatedUploads(multipartUploads);
267
- if (routeStreamStarted) {
268
- await cancelTransferAfterRouteStreamFailure(this, prepared.transfer_id, error);
269
- }
304
+ if (routeStream)
305
+ await routeStream.abort();
306
+ await Promise.allSettled(pendingRoutes);
307
+ if (multipartManifestTask)
308
+ await Promise.allSettled([multipartManifestTask]);
309
+ await cancelAndAbortProviderFailure({
310
+ client: this,
311
+ transferId: prepared.transfer_id,
312
+ cause: error,
313
+ uploads: multipartUploads,
314
+ abortBeforeCancel: !routeStreamBeginAttempted
315
+ });
270
316
  throw error;
271
317
  }
272
318
  return prepared;
@@ -275,43 +321,17 @@ export class BeamClient {
275
321
  if (isHippiusDestination(input.destination)) {
276
322
  return signDestinationRoute({
277
323
  chunk: input.chunk,
278
- target: input.target,
279
- source: input.source,
280
- destination: input.destination,
281
- expiresIn: input.expiresIn,
282
- transferId: input.transferId,
283
- multipartGroupId: input.upload.multipartGroupId,
284
- multipartCreatedAt: input.upload.multipartCreatedAt,
285
- fetchImpl: this.fetchImpl
286
- });
287
- }
288
- if (!input.target.object_key) {
289
- throw new Error("destination signing target is missing object_key");
290
- }
291
- if (input.signedUrlFlow === "signed_url_v1") {
292
- const partVerifyUrl = await signListMultipartUpload(input.destination, input.finalObjectKey, input.upload.uploadId, input.expiresIn, { maxParts: 1, partNumberMarker: listPartsVerifyMarker(input.partNumber) });
293
- const listPageUrl = await signListMultipartUpload(input.destination, input.finalObjectKey, input.upload.uploadId, input.expiresIn, { maxParts: 1000, partNumberMarker: listPartsPageMarker(input.partNumber) });
294
- return signDestinationRoute({
295
- chunk: input.chunk,
296
- target: input.target,
324
+ target: { ...input.target, metadata: partRouteMetadata(input.target.metadata) },
297
325
  source: input.source,
298
326
  destination: input.destination,
299
327
  expiresIn: input.expiresIn,
300
- uploadId: input.upload.uploadId,
301
- partNumber: input.partNumber,
302
- multipartObjectKey: input.finalObjectKey,
303
- completeUrl: input.upload.completeUrl,
304
- abortUrl: input.upload.abortUrl,
305
- listUrl: input.upload.listUrl,
306
- partVerifyUrl,
307
- listPageUrl,
308
- transferId: input.transferId,
309
- multipartGroupId: input.upload.multipartGroupId,
310
- multipartCreatedAt: input.upload.multipartCreatedAt,
311
- commitMethod: "upload_part",
312
328
  fetchImpl: this.fetchImpl
313
329
  });
314
330
  }
331
+ const upload = input.upload;
332
+ if (!upload)
333
+ throw new Error("signed_url_v2 route is missing its multipart group");
334
+ validateMultipartPartNumber(input.partNumber, upload.manifest);
315
335
  const stagingObjectPrefix = buildStagingObjectPrefix({
316
336
  finalObjectKey: input.finalObjectKey,
317
337
  transferId: input.transferId,
@@ -323,27 +343,20 @@ export class BeamClient {
323
343
  `part-${input.partNumber}-chunk-${input.chunk.chunk_index}`
324
344
  ].join("/");
325
345
  const copySourceRange = `bytes=0-${Math.max(0, input.chunk.chunk_size - 1)}`;
326
- const { copyUrl, copySource } = await signUploadPartCopy(input.destination, input.finalObjectKey, stagingObjectKey, input.upload.uploadId, input.partNumber, input.expiresIn, copySourceRange);
327
- const stagingListUrl = await signListObjects(input.destination, stagingObjectPrefix, input.expiresIn);
328
- const deleteUrl = await signDeleteObject(input.destination, stagingObjectKey, input.expiresIn);
346
+ const [{ copyUrl, copySource }, deleteUrl] = await Promise.all([
347
+ signUploadPartCopy(input.destination, input.finalObjectKey, stagingObjectKey, upload.uploadId, input.partNumber, input.expiresIn, copySourceRange),
348
+ signDeleteObject(input.destination, stagingObjectKey, input.expiresIn)
349
+ ]);
329
350
  return signDestinationRoute({
330
351
  chunk: input.chunk,
331
- target: input.target,
352
+ target: { ...input.target, metadata: partRouteMetadata(input.target.metadata) },
332
353
  source: input.source,
333
354
  destination: input.destination,
334
355
  expiresIn: input.expiresIn,
335
- uploadId: input.upload.uploadId,
336
356
  partNumber: input.partNumber,
337
- multipartObjectKey: input.finalObjectKey,
338
- completeUrl: input.upload.completeUrl,
339
- abortUrl: input.upload.abortUrl,
340
- listUrl: input.upload.listUrl,
341
- transferId: input.transferId,
342
- multipartGroupId: input.upload.multipartGroupId,
343
- multipartCreatedAt: input.upload.multipartCreatedAt,
357
+ multipartGroupId: upload.manifest.multipart_group_id,
344
358
  commitMethod: "upload_part_copy",
345
359
  stagingObjectKey,
346
- stagingListUrl,
347
360
  copyUrl,
348
361
  copySource,
349
362
  copySourceRange,
@@ -351,79 +364,6 @@ export class BeamClient {
351
364
  fetchImpl: this.fetchImpl
352
365
  });
353
366
  }
354
- async startProviderRouteRecoverySigner(input) {
355
- this.stopRecoverySigner(input.prepared.transfer_id);
356
- const stop = await this.control.serveRouteRecoverySigner(input.prepared.transfer_id, async (payload) => {
357
- const routes = await mapOrderedWithConcurrency(payload.chunks, this.routeSigningConcurrency, async (requested) => {
358
- const chunk = materializePlanChunk(input.prepared.plan_descriptor, requested.source_id, requested.chunk_index);
359
- if (!chunk) {
360
- throw new Error(`recovery signing chunk not found: ${requested.source_id}:${requested.chunk_index}`);
361
- }
362
- const target = chunk.destinations.find((candidate) => candidate.destination_id === requested.destination_id);
363
- if (!target) {
364
- throw new Error(`recovery signing destination not found: ${requested.destination_id}`);
365
- }
366
- const source = input.sourcesById.get(requested.source_id);
367
- const destination = input.destinationsById.get(requested.destination_id);
368
- if (!source || !destination) {
369
- throw new Error("recovery signing provider configuration missing");
370
- }
371
- const upload = input.multipartUploads.get(requested.multipart_group_id) ?? input.multipartUploads.get(`${input.prepared.transfer_id}:${requested.destination_id}:${requested.source_id}:${requested.final_object_key}`);
372
- if (!upload) {
373
- throw new Error(`recovery signing multipart upload state missing for ${requested.multipart_group_id}`);
374
- }
375
- const metadata = {
376
- ...(target.metadata ?? {}),
377
- ...(requested.destination_metadata ?? {}),
378
- transfer_id: input.prepared.transfer_id,
379
- final_object_key: requested.final_object_key,
380
- upload_id: requested.upload_id ?? upload.uploadId,
381
- multipart_group_id: requested.multipart_group_id,
382
- multipart_created_at: requested.multipart_created_at ?? upload.multipartCreatedAt,
383
- delivery_index: requested.delivery_index,
384
- part_number: requested.part_number,
385
- logical_attempt_index: requested.logical_attempt_index,
386
- attempt_slot: requested.attempt_slot,
387
- route_generation_id: requested.route_generation_id
388
- };
389
- return this.signProviderRoute({
390
- chunk,
391
- target: {
392
- ...target,
393
- object_key: target.object_key ?? requested.final_object_key,
394
- metadata
395
- },
396
- source,
397
- destination,
398
- expiresIn: input.expiresIn,
399
- upload,
400
- transferId: input.prepared.transfer_id,
401
- finalObjectKey: requested.final_object_key,
402
- partNumber: requested.part_number,
403
- signedUrlFlow: input.signedUrlFlow
404
- });
405
- });
406
- return {
407
- transfer_id: payload.transfer_id,
408
- route_generation_id: payload.route_generation_id,
409
- signed_at: nowIso(),
410
- route_batch: compactSignedRoutes(routes)
411
- };
412
- });
413
- this.recoverySigners.set(input.prepared.transfer_id, stop);
414
- }
415
- stopRecoverySigner(transferId) {
416
- const stop = this.recoverySigners.get(transferId);
417
- if (!stop)
418
- return;
419
- stop();
420
- this.recoverySigners.delete(transferId);
421
- }
422
- stopAllRecoverySigners() {
423
- for (const transferId of [...this.recoverySigners.keys()]) {
424
- this.stopRecoverySigner(transferId);
425
- }
426
- }
427
367
  async createAndDistribute(input) {
428
368
  const transfer = await this.createRawTransfer(input);
429
369
  if (transfer.success) {
@@ -433,37 +373,93 @@ export class BeamClient {
433
373
  }
434
374
  async waitForTransfer(transferId, options = {}) {
435
375
  const timeoutMs = options.timeoutMs ?? 300_000;
436
- const pollIntervalMs = options.pollIntervalMs ?? 2_000;
376
+ const pollIntervalMs = options.pollIntervalMs ?? 15_000;
377
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
378
+ throw new Error("timeoutMs must be a positive number.");
379
+ }
380
+ if (!Number.isFinite(pollIntervalMs) || pollIntervalMs <= 0) {
381
+ throw new Error("pollIntervalMs must be a positive number.");
382
+ }
383
+ const configuredMaxPollIntervalMs = options.maxPollIntervalMs ?? 30_000;
384
+ if (!Number.isFinite(configuredMaxPollIntervalMs) || configuredMaxPollIntervalMs <= 0) {
385
+ throw new Error("maxPollIntervalMs must be a positive number.");
386
+ }
387
+ const maxPollIntervalMs = Math.max(pollIntervalMs, configuredMaxPollIntervalMs);
437
388
  const startedAt = Date.now();
438
- while (true) {
439
- const status = await this.transferStatus(transferId);
440
- if (status.status === "completed") {
441
- this.stopRecoverySigner(transferId);
442
- return status;
443
- }
444
- if (status.status === "failed") {
445
- this.stopRecoverySigner(transferId);
446
- throw new Error(`Transfer failed: ${status.error_message ?? "unknown error"}`);
447
- }
448
- if (status.status === "cancelled") {
449
- this.stopRecoverySigner(transferId);
450
- throw new Error("Transfer cancelled");
389
+ let currentPollIntervalMs = pollIntervalMs;
390
+ let terminalWaiter = null;
391
+ try {
392
+ terminalWaiter = await this.openTransferTerminalWaiter(transferId);
393
+ }
394
+ catch {
395
+ // Terminal delivery is advisory; authoritative status remains available.
396
+ }
397
+ try {
398
+ while (true) {
399
+ const status = await this.transferStatus(transferId);
400
+ if (status.status === "completed") {
401
+ return status;
402
+ }
403
+ if (status.status === "failed") {
404
+ throw new Error(`Transfer failed: ${status.error_message ?? "unknown error"}`);
405
+ }
406
+ if (status.status === "cancelled") {
407
+ throw new Error("Transfer cancelled");
408
+ }
409
+ if (Date.now() - startedAt >= timeoutMs) {
410
+ throw new Error(`Transfer ${transferId} did not complete within ${timeoutMs}ms`);
411
+ }
412
+ const remainingMs = timeoutMs - (Date.now() - startedAt);
413
+ const waitMs = Math.min(Math.max(1, remainingMs), jitteredDelay(currentPollIntervalMs));
414
+ let terminalEvent = null;
415
+ if (terminalWaiter) {
416
+ const waitStartedAt = Date.now();
417
+ try {
418
+ terminalEvent = await terminalWaiter.wait(waitMs);
419
+ }
420
+ catch {
421
+ try {
422
+ await terminalWaiter.close();
423
+ }
424
+ catch {
425
+ // The status fallback remains authoritative even if unsubscribe fails.
426
+ }
427
+ terminalWaiter = null;
428
+ const remainingWaitMs = Math.max(0, waitMs - (Date.now() - waitStartedAt));
429
+ if (remainingWaitMs > 0) {
430
+ await new Promise((resolve) => setTimeout(resolve, remainingWaitMs));
431
+ }
432
+ }
433
+ }
434
+ else {
435
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
436
+ }
437
+ if (terminalEvent)
438
+ currentPollIntervalMs = pollIntervalMs;
439
+ else
440
+ currentPollIntervalMs = Math.min(maxPollIntervalMs, Math.ceil(currentPollIntervalMs * 1.5));
451
441
  }
452
- if (Date.now() - startedAt >= timeoutMs) {
453
- throw new Error(`Transfer ${transferId} did not complete within ${timeoutMs}ms`);
442
+ }
443
+ finally {
444
+ if (terminalWaiter) {
445
+ try {
446
+ await terminalWaiter.close();
447
+ }
448
+ catch {
449
+ // Terminal delivery is advisory; closing the client remains the final cleanup boundary.
450
+ }
454
451
  }
455
- await sleep(pollIntervalMs);
456
452
  }
457
453
  }
458
454
  }
459
- const ROUTE_STREAM_BOOTSTRAP_BATCH_ROUTES = 256;
460
- const ROUTE_STREAM_STEADY_BATCH_ROUTES = 2_048;
455
+ const ROUTE_STREAM_BATCH_ROUTES = 2_048;
461
456
  class RouteStreamSender {
462
457
  control;
463
458
  options;
464
- streamId = newTransferId();
459
+ streamId;
465
460
  checksum = new RouteKeysChecksum();
466
461
  batch = [];
462
+ seenDeliveryIndices = new Set();
467
463
  batchIndex = 0;
468
464
  routeCount = 0;
469
465
  sendTail = Promise.resolve();
@@ -471,6 +467,7 @@ class RouteStreamSender {
471
467
  constructor(control, options) {
472
468
  this.control = control;
473
469
  this.options = options;
470
+ this.streamId = options.streamId;
474
471
  }
475
472
  async begin() {
476
473
  await this.control.request("transfer.route_stream.begin", compact({
@@ -478,26 +475,55 @@ class RouteStreamSender {
478
475
  stream_id: this.streamId,
479
476
  total_routes: this.options.totalRoutes,
480
477
  total_chunks: this.options.totalChunks,
478
+ route_contract_version: "signed_url_v2",
481
479
  urls_expires_at: this.options.urlsExpiresAt,
482
- signed_url_flow: this.options.signedUrlFlow,
480
+ signed_url_flow: "signed_url_v2",
483
481
  auto_distribute: this.options.autoDistribute
484
- }), this.options.transferId);
482
+ }), {
483
+ transferId: this.options.transferId,
484
+ idempotencyKey: `transfer:${this.options.transferId}:route-stream:${this.streamId}:begin`
485
+ });
486
+ }
487
+ async addManifestGroups(groups) {
488
+ if (!groups.length)
489
+ return;
490
+ await Promise.all(groups.map(async (group) => {
491
+ const identity = await stableManifestBatchIdentity([group]);
492
+ await this.control.request("transfer.route_stream.manifest", {
493
+ transfer_id: this.options.transferId,
494
+ stream_id: this.streamId,
495
+ manifest_batch_id: identity,
496
+ groups: [group]
497
+ }, {
498
+ transferId: this.options.transferId,
499
+ idempotencyKey: `transfer:${this.options.transferId}:route-stream:${this.streamId}:manifest:${identity}`
500
+ });
501
+ }));
485
502
  }
486
- addRoute(route) {
487
- const normalizedRoute = route.delivery_index === undefined
488
- ? { ...route, delivery_index: this.routeCount }
489
- : route;
503
+ async addRoute(route) {
504
+ const deliveryIndex = signedRouteDeliveryIndex(route);
505
+ if (deliveryIndex === undefined) {
506
+ throw new Error("route delivery_index is required");
507
+ }
508
+ if (!Number.isSafeInteger(deliveryIndex) || deliveryIndex < 0 || deliveryIndex >= this.options.totalRoutes) {
509
+ throw new Error(`route delivery_index ${deliveryIndex} is outside the declared route stream`);
510
+ }
511
+ if (this.seenDeliveryIndices.has(deliveryIndex)) {
512
+ throw new Error(`duplicate route delivery_index ${deliveryIndex}`);
513
+ }
514
+ this.seenDeliveryIndices.add(deliveryIndex);
490
515
  this.routeCount += 1;
491
- this.batch.push(normalizedRoute);
492
- const target = this.batchIndex === 0
493
- ? ROUTE_STREAM_BOOTSTRAP_BATCH_ROUTES
494
- : ROUTE_STREAM_STEADY_BATCH_ROUTES;
495
- if (this.batch.length >= target) {
496
- this.enqueueFlush();
516
+ this.batch.push({ ...route, delivery_index: deliveryIndex });
517
+ if (this.batch.length >= ROUTE_STREAM_BATCH_ROUTES) {
518
+ await this.enqueueFlush();
497
519
  }
498
520
  }
499
521
  async complete() {
500
- this.enqueueFlush();
522
+ if (this.routeCount !== this.options.totalRoutes ||
523
+ this.seenDeliveryIndices.size !== this.options.totalRoutes) {
524
+ throw new Error(`route stream has incomplete or duplicate delivery indices: received ${this.routeCount} of ${this.options.totalRoutes} routes`);
525
+ }
526
+ await this.enqueueFlush();
501
527
  await this.sendTail;
502
528
  if (this.sendError)
503
529
  throw this.sendError;
@@ -507,11 +533,17 @@ class RouteStreamSender {
507
533
  expected_batches: this.batchIndex,
508
534
  expected_routes: this.options.totalRoutes,
509
535
  route_keys_checksum: this.checksum.value()
510
- }, this.options.transferId);
536
+ }, {
537
+ transferId: this.options.transferId,
538
+ idempotencyKey: `transfer:${this.options.transferId}:route-stream:${this.streamId}:complete`
539
+ });
511
540
  }
512
- enqueueFlush() {
541
+ async enqueueFlush() {
513
542
  if (!this.batch.length)
514
543
  return;
544
+ await this.sendTail;
545
+ if (this.sendError)
546
+ throw this.sendError;
515
547
  const routes = this.batch.splice(0, this.batch.length);
516
548
  const chunks = this.control.splitRoutesForPayload("transfer.route_stream.batch", {
517
549
  transfer_id: this.options.transferId,
@@ -529,15 +561,21 @@ class RouteStreamSender {
529
561
  const chunkChecksum = new RouteKeysChecksum();
530
562
  await chunkChecksum.addMany(chunk.map(routeKeyForSignedRoute));
531
563
  this.checksum.merge(chunkChecksum);
564
+ const routeChecksum = chunkChecksum.value();
565
+ const coordinateChecksum = await routeCoordinateChecksum(chunk);
566
+ const batchId = `${this.streamId}:${batchIndex}:${coordinateChecksum}`;
532
567
  await this.control.request("transfer.route_stream.batch", {
533
568
  transfer_id: this.options.transferId,
534
569
  stream_id: this.streamId,
535
- batch_id: `${this.streamId}:${batchIndex}`,
570
+ batch_id: batchId,
536
571
  batch_index: batchIndex,
537
572
  route_batch: compactSignedRoutes(chunk),
538
573
  route_count: chunk.length,
539
- route_keys_checksum: chunkChecksum.value()
540
- }, this.options.transferId);
574
+ route_keys_checksum: routeChecksum
575
+ }, {
576
+ transferId: this.options.transferId,
577
+ idempotencyKey: `transfer:${this.options.transferId}:route-stream:${this.streamId}:batch:${batchIndex}:${coordinateChecksum}`
578
+ });
541
579
  }
542
580
  catch (error) {
543
581
  this.sendError = error;
@@ -545,8 +583,9 @@ class RouteStreamSender {
545
583
  });
546
584
  }
547
585
  }
548
- abort() {
586
+ async abort() {
549
587
  this.sendError ??= new Error("route_stream_aborted");
588
+ await this.sendTail;
550
589
  }
551
590
  }
552
591
  class RouteKeysChecksum {
@@ -574,13 +613,46 @@ class RouteKeysChecksum {
574
613
  function routeKeyForSignedRoute(route) {
575
614
  return `${route.source_id}:${route.destination_id}:${route.chunk_index}`;
576
615
  }
616
+ function signedRouteDeliveryIndex(route) {
617
+ const metadataIndex = route.metadata?.delivery_index;
618
+ if (typeof metadataIndex === "number")
619
+ return metadataIndex;
620
+ return route.delivery_index;
621
+ }
622
+ function compareSignedRoutesByDelivery(left, right) {
623
+ const leftIndex = signedRouteDeliveryIndex(left);
624
+ const rightIndex = signedRouteDeliveryIndex(right);
625
+ if (leftIndex !== undefined && rightIndex !== undefined && leftIndex !== rightIndex) {
626
+ return leftIndex - rightIndex;
627
+ }
628
+ return routeKeyForSignedRoute(left).localeCompare(routeKeyForSignedRoute(right));
629
+ }
630
+ async function routeCoordinateChecksum(routes) {
631
+ const checksum = new RouteKeysChecksum();
632
+ await checksum.addMany(routes.map((route) => `${routeKeyForSignedRoute(route)}:${signedRouteDeliveryIndex(route) ?? "missing"}`));
633
+ return checksum.value();
634
+ }
635
+ async function stableRouteStreamId(input) {
636
+ const digest = await sha256Bytes(`beam:route-stream:signed_url_v2:${input.transferId}:${input.planIdentity}:${input.totalRoutes}:${input.totalChunks}`);
637
+ const bytes = digest.slice(0, 16);
638
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x50;
639
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
640
+ const value = hex(bytes);
641
+ return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}`;
642
+ }
643
+ async function stableManifestBatchIdentity(groups) {
644
+ const identities = groups
645
+ .map((group) => `${group.multipart_group_id}:${group.source_id}:${group.destination_id}`)
646
+ .sort();
647
+ return hex((await sha256Bytes(identities.join("\n"))).slice(0, 16));
648
+ }
577
649
  function countDistinctRouteChunks(routes) {
578
650
  return new Set(routes.map((route) => `${route.source_id}:${route.chunk_index}`)).size;
579
651
  }
580
- function* materializePlanChunks(descriptor) {
652
+ function* materializePlanChunks(descriptor, transferId) {
581
653
  for (const source of descriptor.sources) {
582
654
  for (let sourceChunkIndex = 0; sourceChunkIndex < source.chunk_count; sourceChunkIndex += 1) {
583
- yield materializePlanChunk(descriptor, source.source_id, source.global_chunk_start + sourceChunkIndex);
655
+ yield materializePlanChunk(descriptor, transferId, source.source_id, source.global_chunk_start + sourceChunkIndex);
584
656
  }
585
657
  }
586
658
  }
@@ -604,7 +676,7 @@ function positiveInteger(value, label) {
604
676
  throw new Error(`${label} must be a positive integer.`);
605
677
  return value;
606
678
  }
607
- function materializePlanChunk(descriptor, sourceId, chunkIndex) {
679
+ function materializePlanChunk(descriptor, transferId, sourceId, chunkIndex) {
608
680
  const source = descriptor.sources.find((candidate) => candidate.source_id === sourceId);
609
681
  if (!source)
610
682
  throw new Error(`plan source not found: ${sourceId}`);
@@ -626,15 +698,26 @@ function materializePlanChunk(descriptor, sourceId, chunkIndex) {
626
698
  if (!finalObjectKey)
627
699
  throw new Error(`plan final object key not found: ${sourceId}:${destination.destination_id}`);
628
700
  const deliveryIndex = chunkIndex * descriptor.destinations.length + destination.destination_index;
701
+ const partNumber = multipartPartNumber(sourceChunkIndex);
702
+ const objectKey = destination.provider.toLowerCase() === "hippius"
703
+ ? `${finalObjectKey}/${descriptor.plan_nonce}/chunk-${String(sourceChunkIndex).padStart(6, "0")}`
704
+ : buildStagingObjectKey({
705
+ finalObjectKey,
706
+ transferId,
707
+ sourceId,
708
+ destinationId: destination.destination_id,
709
+ chunkIndex,
710
+ partNumber
711
+ });
629
712
  return {
630
713
  destination_id: destination.destination_id,
631
714
  delivery_index: deliveryIndex,
632
715
  provider: destination.provider,
633
- object_key: `${finalObjectKey}/${descriptor.plan_nonce}/chunk-${String(sourceChunkIndex).padStart(6, "0")}`,
716
+ object_key: objectKey,
634
717
  metadata: {
635
718
  ...(destination.metadata ?? {}),
636
719
  final_object_key: finalObjectKey,
637
- part_number: directMultipartInitialPartNumber(chunkIndex),
720
+ part_number: partNumber,
638
721
  logical_attempt_index: 0,
639
722
  attempt_slot: 0,
640
723
  route_generation_id: `initial-${chunkIndex}-${destination.destination_id}`,
@@ -655,33 +738,424 @@ async function sha256Bytes(value) {
655
738
  function hex(bytes) {
656
739
  return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
657
740
  }
658
- async function cancelTransferAfterRouteStreamFailure(client, transferId, cause) {
741
+ async function cancelTransferAfterProviderFailure(client, transferId, cause) {
659
742
  try {
660
- await client.cancelTransfer(transferId);
743
+ const result = await client.cancelTransfer(transferId);
744
+ if (!result.success) {
745
+ throw new Error(result.message ?? `Beam rejected cancellation for ${transferId}`);
746
+ }
661
747
  }
662
748
  catch (cancelError) {
663
- throw new Error(`route streaming failed (${errorMessage(cause)}) and transfer cancellation failed (${errorMessage(cancelError)})`);
749
+ throw new Error(`provider transfer failed (${safeErrorCode(cause)}) and transfer cancellation failed (${safeErrorCode(cancelError)})`);
664
750
  }
665
751
  }
666
- function errorMessage(error) {
667
- return error instanceof Error ? error.message : String(error);
752
+ async function cancelAndAbortProviderFailure(input) {
753
+ let cleanupError;
754
+ let cleanupFailed = false;
755
+ let cancelError;
756
+ if (input.abortBeforeCancel) {
757
+ try {
758
+ await abortCreatedUploads(input.uploads);
759
+ }
760
+ catch (error) {
761
+ cleanupError = error;
762
+ cleanupFailed = true;
763
+ }
764
+ }
765
+ try {
766
+ await cancelTransferAfterProviderFailure(input.client, input.transferId, input.cause);
767
+ }
768
+ catch (error) {
769
+ cancelError = error;
770
+ }
771
+ if (!input.abortBeforeCancel || cleanupFailed) {
772
+ try {
773
+ await abortCreatedUploads(input.uploads);
774
+ cleanupError = undefined;
775
+ cleanupFailed = false;
776
+ }
777
+ catch (error) {
778
+ cleanupError = error;
779
+ cleanupFailed = true;
780
+ }
781
+ }
782
+ if (cancelError && cleanupFailed) {
783
+ throw new AggregateError([input.cause, cancelError, cleanupError], `provider transfer, transfer cancellation, and multipart cleanup failed for ${input.transferId}`);
784
+ }
785
+ if (cancelError)
786
+ throw cancelError;
787
+ if (cleanupFailed) {
788
+ throw new AggregateError([input.cause, cleanupError], `provider transfer failed and multipart cleanup failed for ${input.transferId}`);
789
+ }
790
+ }
791
+ function safeErrorCode(error) {
792
+ if (!(error instanceof Error))
793
+ return "UnknownError";
794
+ const record = error;
795
+ const code = typeof record.code === "string" && /^[A-Za-z0-9_.-]{1,64}$/.test(record.code)
796
+ ? record.code
797
+ : error.name;
798
+ const status = [record.status, record.statusCode]
799
+ .find((value) => Number.isInteger(value) && Number(value) >= 100 && Number(value) <= 599);
800
+ return status === undefined ? code : `${code}:status=${status}`;
801
+ }
802
+ function createDeferred() {
803
+ let resolve;
804
+ let reject;
805
+ const promise = new Promise((resolvePromise, rejectPromise) => {
806
+ resolve = resolvePromise;
807
+ reject = rejectPromise;
808
+ });
809
+ return { promise, resolve, reject };
810
+ }
811
+ function createMultipartGroupWaiters(prepared, destinationsById) {
812
+ const waiters = new Map();
813
+ for (const source of prepared.plan_descriptor.sources) {
814
+ for (const destination of prepared.plan_descriptor.destinations) {
815
+ const destinationConfig = destinationsById.get(destination.destination_id);
816
+ if (!destinationConfig || isHippiusDestination(destinationConfig))
817
+ continue;
818
+ const finalObjectKey = destination.final_object_keys[source.source_id];
819
+ if (!finalObjectKey)
820
+ throw new Error(`plan final object key not found: ${source.source_id}:${destination.destination_id}`);
821
+ const groupId = multipartGroupStateKey(prepared.transfer_id, destination.destination_id, source.source_id, finalObjectKey);
822
+ waiters.set(groupId, createDeferred());
823
+ }
824
+ }
825
+ return waiters;
668
826
  }
669
- function newTransferId() {
827
+ async function createMultipartGroupManifest(input) {
828
+ const groups = input.prepared.plan_descriptor.sources.flatMap((source) => input.prepared.plan_descriptor.destinations.map((destination) => ({ source, destination })));
829
+ const results = await mapOrderedWithConcurrency(groups, input.concurrency, async ({ source, destination }) => {
830
+ let multipartGroupId = null;
831
+ try {
832
+ const destinationConfig = input.destinationsById.get(destination.destination_id);
833
+ if (!destinationConfig) {
834
+ throw new Error(`BeamCore returned unknown destination_id: ${destination.destination_id}`);
835
+ }
836
+ if (isHippiusDestination(destinationConfig)) {
837
+ return { ok: true, state: null };
838
+ }
839
+ const finalObjectKey = destination.final_object_keys[source.source_id];
840
+ if (!finalObjectKey) {
841
+ throw new Error(`plan final object key not found: ${source.source_id}:${destination.destination_id}`);
842
+ }
843
+ multipartGroupId = multipartGroupStateKey(input.prepared.transfer_id, destination.destination_id, source.source_id, finalObjectKey);
844
+ const finalObjectMetadata = {
845
+ "beam-transfer-id": input.prepared.transfer_id,
846
+ "beam-multipart-group-id": multipartGroupId
847
+ };
848
+ const uploadId = await createMultipartUpload({
849
+ destination: destinationConfig,
850
+ objectKey: finalObjectKey,
851
+ metadata: finalObjectMetadata
852
+ });
853
+ try {
854
+ const urlsExpiresAt = expiresAtIso(input.expiresIn);
855
+ const stagingObjectPrefix = buildStagingObjectPrefix({
856
+ finalObjectKey,
857
+ transferId: input.prepared.transfer_id,
858
+ sourceId: source.source_id,
859
+ destinationId: destination.destination_id
860
+ });
861
+ const stagingListPrefix = `${stagingObjectPrefix}/`;
862
+ const maxPartNumber = multipartMaxPartNumber(source.chunk_count);
863
+ const listPageMarkers = multipartListPageMarkers(maxPartNumber);
864
+ const stagingObjectKeys = multipartStagingObjectKeys({
865
+ finalObjectKey,
866
+ transferId: input.prepared.transfer_id,
867
+ sourceId: source.source_id,
868
+ destinationId: destination.destination_id,
869
+ globalChunkStart: source.global_chunk_start,
870
+ chunkCount: source.chunk_count
871
+ });
872
+ const stagingListStartAfter = pagedListStartAfter(stagingObjectKeys);
873
+ const [completeUrl, abortUrl, listPageUrls, stagingListUrls, finalHeadUrl] = await Promise.all([
874
+ signCompleteMultipartUpload(destinationConfig, finalObjectKey, uploadId, input.expiresIn),
875
+ signAbortMultipartUpload(destinationConfig, finalObjectKey, uploadId, input.expiresIn),
876
+ Promise.all(listPageMarkers.map((partNumberMarker) => signListMultipartUpload(destinationConfig, finalObjectKey, uploadId, input.expiresIn, { maxParts: 1_000, ...(partNumberMarker ? { partNumberMarker } : {}) }))),
877
+ Promise.all(stagingListStartAfter.map((startAfter) => signListObjects(destinationConfig, stagingListPrefix, input.expiresIn, startAfter ? { startAfter } : {}))),
878
+ signFinalObjectHead(destinationConfig, finalObjectKey, input.expiresIn)
879
+ ]);
880
+ const manifest = {
881
+ multipart_group_id: multipartGroupId,
882
+ source_id: source.source_id,
883
+ destination_id: destination.destination_id,
884
+ final_object_key: finalObjectKey,
885
+ upload_id: uploadId,
886
+ expected_object_size: source.size,
887
+ expected_part_count: source.chunk_count,
888
+ max_part_number: maxPartNumber,
889
+ complete_url: completeUrl,
890
+ abort_url: abortUrl,
891
+ list_page_urls: listPageUrls,
892
+ staging_list_urls: stagingListUrls,
893
+ final_head_url: finalHeadUrl,
894
+ final_object_metadata: finalObjectMetadata,
895
+ urls_expires_at: urlsExpiresAt
896
+ };
897
+ const state = {
898
+ destination: destinationConfig,
899
+ objectKey: finalObjectKey,
900
+ uploadId,
901
+ manifest
902
+ };
903
+ input.multipartUploads.set(multipartGroupId, state);
904
+ await input.onGroupReady(state);
905
+ return { ok: true, state };
906
+ }
907
+ catch (error) {
908
+ try {
909
+ await abortMultipartUpload(destinationConfig, finalObjectKey, uploadId);
910
+ }
911
+ catch (abortError) {
912
+ throw new AggregateError([error, abortError], `multipart group setup and cleanup failed for ${multipartGroupId}`);
913
+ }
914
+ throw error;
915
+ }
916
+ }
917
+ catch (error) {
918
+ if (multipartGroupId)
919
+ input.onGroupFailed(multipartGroupId, error);
920
+ return { ok: false, error };
921
+ }
922
+ });
923
+ const failure = results.find((result) => !result.ok);
924
+ if (failure && !failure.ok) {
925
+ try {
926
+ await abortCreatedUploads(input.multipartUploads);
927
+ }
928
+ catch (cleanupError) {
929
+ input.multipartUploads.clear();
930
+ throw new AggregateError([failure.error, cleanupError], "multipart manifest setup and cleanup failed");
931
+ }
932
+ input.multipartUploads.clear();
933
+ throw failure.error;
934
+ }
935
+ return results.flatMap((result) => result.ok && result.state ? [result.state.manifest] : []);
936
+ }
937
+ function multipartListPageMarkers(maxPartNumber) {
938
+ const markers = [];
939
+ for (let marker = 0; marker < maxPartNumber; marker += 1_000)
940
+ markers.push(marker);
941
+ return markers;
942
+ }
943
+ function multipartStagingObjectKeys(input) {
944
+ const encoder = new TextEncoder();
945
+ const keys = Array.from({ length: input.chunkCount }, (_, sourceChunkIndex) => {
946
+ const key = buildStagingObjectKey({
947
+ finalObjectKey: input.finalObjectKey,
948
+ transferId: input.transferId,
949
+ sourceId: input.sourceId,
950
+ destinationId: input.destinationId,
951
+ chunkIndex: input.globalChunkStart + sourceChunkIndex,
952
+ partNumber: multipartPartNumber(sourceChunkIndex)
953
+ });
954
+ return { key, bytes: encoder.encode(key) };
955
+ });
956
+ keys.sort((left, right) => compareBytes(left.bytes, right.bytes));
957
+ return keys.map(({ key }) => key);
958
+ }
959
+ function pagedListStartAfter(sortedKeys) {
960
+ const boundaries = [];
961
+ for (let pageStart = 0; pageStart < sortedKeys.length; pageStart += 1_000) {
962
+ boundaries.push(pageStart === 0 ? undefined : sortedKeys[pageStart - 1]);
963
+ }
964
+ return boundaries;
965
+ }
966
+ function compareBytes(leftBytes, rightBytes) {
967
+ const sharedLength = Math.min(leftBytes.length, rightBytes.length);
968
+ for (let index = 0; index < sharedLength; index += 1) {
969
+ const difference = (leftBytes[index] ?? 0) - (rightBytes[index] ?? 0);
970
+ if (difference !== 0)
971
+ return difference;
972
+ }
973
+ return leftBytes.length - rightBytes.length;
974
+ }
975
+ function multipartMaxPartNumber(chunkCount) {
976
+ return multipartPartNumber(positiveInteger(chunkCount, "source chunk_count") - 1);
977
+ }
978
+ function validateMultipartPartNumber(partNumber, manifest) {
979
+ if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > manifest.max_part_number) {
980
+ throw new Error(`multipart part_number ${partNumber} is outside group ${manifest.multipart_group_id} range 1-${manifest.max_part_number}`);
981
+ }
982
+ }
983
+ function validateSignedRouteManifestContract(routes, manifest) {
984
+ const groups = new Map(manifest.map((group) => [group.multipart_group_id, group]));
985
+ for (const route of routes) {
986
+ const groupId = typeof route.metadata?.multipart_group_id === "string"
987
+ ? route.metadata.multipart_group_id
988
+ : undefined;
989
+ if (!groupId)
990
+ continue;
991
+ const group = groups.get(groupId);
992
+ if (!group)
993
+ throw new Error(`signed route references unknown multipart group ${groupId}`);
994
+ if (route.source_id !== group.source_id || route.destination_id !== group.destination_id) {
995
+ throw new Error(`signed route identity does not match multipart group ${groupId}`);
996
+ }
997
+ const partNumber = route.metadata?.part_number;
998
+ if (typeof partNumber !== "number") {
999
+ throw new Error(`signed route for multipart group ${groupId} is missing part_number`);
1000
+ }
1001
+ validateMultipartPartNumber(partNumber, group);
1002
+ }
1003
+ }
1004
+ const COMPACT_TRANSFER_PLAN_KEYS = new Set([
1005
+ "version",
1006
+ "plan_nonce",
1007
+ "chunk_size",
1008
+ "sources",
1009
+ "destinations",
1010
+ "logical_chunk_count",
1011
+ "delivery_route_count",
1012
+ "formulas"
1013
+ ]);
1014
+ const COMPACT_TRANSFER_PLAN_FORMULAS = {
1015
+ source_offset: "source_chunk_index * chunk_size",
1016
+ delivery_index: "chunk_index * destination_count + destination_index",
1017
+ part_number: "source_chunk_index + 1",
1018
+ route_generation_id: "initial-{chunk_index}-{destination_id}",
1019
+ staging_object_key: "{final_object_key}.beam-staging/{transfer_id}/{source_id}/{destination_id}/part-{part_number}-chunk-{chunk_index}"
1020
+ };
1021
+ function validateCompactTransferPlan(descriptor, signedUrlFlow) {
1022
+ if (signedUrlFlow !== "signed_url_v2") {
1023
+ throw new Error(`BeamCore returned unsupported signed_url_flow: ${String(signedUrlFlow)}`);
1024
+ }
1025
+ const keys = Object.keys(descriptor);
1026
+ if (keys.length !== COMPACT_TRANSFER_PLAN_KEYS.size || keys.some((key) => !COMPACT_TRANSFER_PLAN_KEYS.has(key))) {
1027
+ throw new Error("BeamCore returned invalid compact transfer plan fields");
1028
+ }
1029
+ if (descriptor.version !== "compact-transfer-plan/v1") {
1030
+ throw new Error(`BeamCore returned unsupported plan version: ${descriptor.version}`);
1031
+ }
1032
+ const formulaKeys = Object.keys(descriptor.formulas);
1033
+ if (formulaKeys.length !== Object.keys(COMPACT_TRANSFER_PLAN_FORMULAS).length
1034
+ || Object.entries(COMPACT_TRANSFER_PLAN_FORMULAS).some(([key, value]) => descriptor.formulas[key] !== value)) {
1035
+ throw new Error("BeamCore returned unsupported compact transfer plan formulas");
1036
+ }
1037
+ }
1038
+ const MULTIPART_GROUP_MANIFEST_KEYS = new Set([
1039
+ "multipart_group_id",
1040
+ "source_id",
1041
+ "destination_id",
1042
+ "final_object_key",
1043
+ "upload_id",
1044
+ "expected_object_size",
1045
+ "expected_part_count",
1046
+ "max_part_number",
1047
+ "complete_url",
1048
+ "abort_url",
1049
+ "list_page_urls",
1050
+ "staging_list_urls",
1051
+ "final_head_url",
1052
+ "final_object_metadata",
1053
+ "urls_expires_at"
1054
+ ]);
1055
+ function validateMultipartGroupManifest(manifest, transferId) {
1056
+ const groupIds = new Set();
1057
+ for (const group of manifest) {
1058
+ const keys = Object.keys(group);
1059
+ if (keys.length !== MULTIPART_GROUP_MANIFEST_KEYS.size || keys.some((key) => !MULTIPART_GROUP_MANIFEST_KEYS.has(key))) {
1060
+ throw new Error(`multipart group ${group.multipart_group_id || "<unknown>"} has invalid manifest fields`);
1061
+ }
1062
+ if (!group.multipart_group_id || groupIds.has(group.multipart_group_id)) {
1063
+ throw new Error(`multipart_group_id must be non-empty and unique: ${group.multipart_group_id}`);
1064
+ }
1065
+ groupIds.add(group.multipart_group_id);
1066
+ for (const [field, value] of Object.entries({
1067
+ source_id: group.source_id,
1068
+ destination_id: group.destination_id,
1069
+ final_object_key: group.final_object_key,
1070
+ upload_id: group.upload_id,
1071
+ complete_url: group.complete_url,
1072
+ abort_url: group.abort_url,
1073
+ final_head_url: group.final_head_url,
1074
+ urls_expires_at: group.urls_expires_at
1075
+ })) {
1076
+ if (typeof value !== "string" || value.length === 0) {
1077
+ throw new Error(`multipart group ${group.multipart_group_id} requires ${field}`);
1078
+ }
1079
+ }
1080
+ if (!Number.isInteger(group.expected_part_count) || group.expected_part_count < 1 || group.expected_part_count > 10_000) {
1081
+ throw new Error(`multipart group ${group.multipart_group_id} has invalid expected_part_count`);
1082
+ }
1083
+ if (group.max_part_number !== group.expected_part_count) {
1084
+ throw new Error(`multipart group ${group.multipart_group_id} max_part_number must equal expected_part_count`);
1085
+ }
1086
+ if (!Number.isSafeInteger(group.expected_object_size) || group.expected_object_size <= 0) {
1087
+ throw new Error(`multipart group ${group.multipart_group_id} has invalid expected_object_size`);
1088
+ }
1089
+ const expectedListPageCount = Math.ceil(group.max_part_number / 1_000);
1090
+ if (!Array.isArray(group.list_page_urls)
1091
+ || group.list_page_urls.length !== expectedListPageCount
1092
+ || new Set(group.list_page_urls).size !== expectedListPageCount
1093
+ || group.list_page_urls.some((url) => typeof url !== "string" || url.length === 0)) {
1094
+ throw new Error(`multipart group ${group.multipart_group_id} requires ${expectedListPageCount} unique list_page_urls`);
1095
+ }
1096
+ if (!Array.isArray(group.staging_list_urls)
1097
+ || group.staging_list_urls.length !== expectedListPageCount
1098
+ || new Set(group.staging_list_urls).size !== expectedListPageCount
1099
+ || group.staging_list_urls.some((url) => typeof url !== "string" || url.length === 0)) {
1100
+ throw new Error(`multipart group ${group.multipart_group_id} requires ${expectedListPageCount} unique staging_list_urls`);
1101
+ }
1102
+ const metadataKeys = Object.keys(group.final_object_metadata);
1103
+ if (metadataKeys.length !== 2
1104
+ || group.final_object_metadata["beam-transfer-id"] !== transferId
1105
+ || group.final_object_metadata["beam-multipart-group-id"] !== group.multipart_group_id) {
1106
+ throw new Error(`multipart group ${group.multipart_group_id} has invalid final_object_metadata`);
1107
+ }
1108
+ }
1109
+ }
1110
+ function multipartGroupStateKey(transferId, destinationId, sourceId, finalObjectKey) {
1111
+ return `${transferId}:${destinationId}:${sourceId}:${finalObjectKey}`;
1112
+ }
1113
+ const PART_ROUTE_METADATA_KEYS = new Set([
1114
+ "part_number",
1115
+ "logical_attempt_index",
1116
+ "attempt_slot",
1117
+ "route_generation_id",
1118
+ "delivery_index"
1119
+ ]);
1120
+ function partRouteMetadata(metadata) {
1121
+ return Object.fromEntries(Object.entries(metadata ?? {}).filter(([key]) => PART_ROUTE_METADATA_KEYS.has(key)));
1122
+ }
1123
+ function randomUuid() {
670
1124
  const cryptoApi = globalThis.crypto;
671
1125
  if (cryptoApi?.randomUUID)
672
1126
  return cryptoApi.randomUUID();
673
1127
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
674
1128
  }
1129
+ async function transferIdForIdempotencyKey(idempotencyKey) {
1130
+ if (!idempotencyKey?.trim())
1131
+ return randomUuid();
1132
+ const input = new TextEncoder().encode(`beam-transfer:${idempotencyKey.trim()}`);
1133
+ let digest;
1134
+ if (globalThis.crypto?.subtle) {
1135
+ digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", input));
1136
+ }
1137
+ else {
1138
+ const { createHash } = await import("node:crypto");
1139
+ digest = new Uint8Array(createHash("sha256").update(input).digest());
1140
+ }
1141
+ const bytes = digest.slice(0, 16);
1142
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x50;
1143
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
1144
+ const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
1145
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
1146
+ }
1147
+ function jitteredDelay(baseMs) {
1148
+ return Math.max(1, Math.round(baseMs * (0.8 + Math.random() * 0.4)));
1149
+ }
675
1150
  function compact(input) {
676
1151
  return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
677
1152
  }
678
1153
  export function buildStagingObjectPrefix(input) {
679
- const base = input.finalObjectKey.replace(/\/+$/, "") || "object";
680
1154
  return [
681
- `${base}.beam-staging`,
682
- safeObjectKeySegment(input.transferId),
683
- safeObjectKeySegment(input.sourceId),
684
- safeObjectKeySegment(input.destinationId)
1155
+ `${input.finalObjectKey}.beam-staging`,
1156
+ input.transferId,
1157
+ input.sourceId,
1158
+ input.destinationId
685
1159
  ].join("/");
686
1160
  }
687
1161
  export function buildStagingObjectKey(input) {
@@ -690,21 +1164,26 @@ export function buildStagingObjectKey(input) {
690
1164
  `part-${input.partNumber}-chunk-${input.chunkIndex}`
691
1165
  ].join("/");
692
1166
  }
693
- function safeObjectKeySegment(value) {
694
- return encodeURIComponent(value).replace(/%2F/gi, "_");
695
- }
696
1167
  function validateId(value, name) {
697
1168
  if (!value || value.includes("/") || value.includes("..")) {
698
1169
  throw new Error(`${name} is invalid`);
699
1170
  }
700
1171
  }
701
1172
  async function abortCreatedUploads(uploads) {
702
- await Promise.all([...uploads.values()].map(async (upload) => {
703
- if (!upload.uploadId || isHippiusDestination(upload.destination)) {
704
- return;
1173
+ const candidates = [...uploads.values()].filter((upload) => upload.uploadId && !isHippiusDestination(upload.destination));
1174
+ const results = await mapOrderedWithConcurrency(candidates, Math.min(32, Math.max(1, candidates.length)), async (upload) => {
1175
+ try {
1176
+ await abortMultipartUpload(upload.destination, upload.objectKey, upload.uploadId);
1177
+ return null;
1178
+ }
1179
+ catch (error) {
1180
+ return error;
705
1181
  }
706
- await abortMultipartUpload(upload.destination, upload.objectKey, upload.uploadId);
707
- }));
1182
+ });
1183
+ const failures = results.filter((result) => result !== null);
1184
+ if (failures.length) {
1185
+ throw new AggregateError(failures, `failed to abort ${failures.length} multipart upload(s)`);
1186
+ }
708
1187
  }
709
1188
  function isHippiusDestination(destination) {
710
1189
  return destination.provider === "hippius" && "api_token" in destination;