@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/README.md +6 -1
- package/dist/client.d.ts +6 -6
- package/dist/client.js +765 -286
- package/dist/models.d.ts +62 -11
- package/dist/multipart-limits.d.ts +3 -4
- package/dist/multipart-limits.js +8 -9
- package/dist/nats-control.d.ts +32 -44
- package/dist/nats-control.js +287 -133
- package/dist/provider-signing.d.ts +10 -15
- package/dist/provider-signing.js +50 -59
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { abortMultipartUpload, createMultipartUpload,
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 },
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
97
|
-
|
|
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:
|
|
105
|
-
}),
|
|
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:
|
|
112
|
-
totalChunks: countDistinctRouteChunks(
|
|
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
|
-
|
|
119
|
-
stream.
|
|
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 =
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
:
|
|
249
|
-
signedUrlFlow
|
|
286
|
+
: multipartPartNumber(chunk.source_chunk_index),
|
|
250
287
|
});
|
|
251
|
-
})()
|
|
252
|
-
|
|
253
|
-
|
|
288
|
+
})();
|
|
289
|
+
pendingRoutes.add(pendingRoute);
|
|
290
|
+
if (pendingRoutes.size >= signingConcurrency)
|
|
291
|
+
await streamNextCompletedRoute();
|
|
254
292
|
}
|
|
255
293
|
}
|
|
256
|
-
while (pendingRoutes.
|
|
257
|
-
await
|
|
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
|
|
265
|
-
|
|
266
|
-
await
|
|
267
|
-
if (
|
|
268
|
-
await
|
|
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
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
453
|
-
|
|
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
|
|
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
|
|
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:
|
|
480
|
+
signed_url_flow: "signed_url_v2",
|
|
483
481
|
auto_distribute: this.options.autoDistribute
|
|
484
|
-
}),
|
|
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
|
|
488
|
-
|
|
489
|
-
|
|
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(
|
|
492
|
-
|
|
493
|
-
|
|
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.
|
|
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
|
-
},
|
|
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:
|
|
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:
|
|
540
|
-
},
|
|
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:
|
|
716
|
+
object_key: objectKey,
|
|
634
717
|
metadata: {
|
|
635
718
|
...(destination.metadata ?? {}),
|
|
636
719
|
final_object_key: finalObjectKey,
|
|
637
|
-
part_number:
|
|
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
|
|
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(`
|
|
749
|
+
throw new Error(`provider transfer failed (${safeErrorCode(cause)}) and transfer cancellation failed (${safeErrorCode(cancelError)})`);
|
|
664
750
|
}
|
|
665
751
|
}
|
|
666
|
-
function
|
|
667
|
-
|
|
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
|
|
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
|
-
`${
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
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;
|