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