@lossless.org/client 2.1.0 → 2.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.
@@ -18,6 +18,10 @@ import {
18
18
  purgeExactPath as purgeExactPathOperation,
19
19
  } from './internal.exactpathpurge.operations.js';
20
20
  import { beginBasicBucketOperation } from './internal.exactupload.capability.js';
21
+ import {
22
+ copyObjectMultipart,
23
+ multipartCopyThresholdBytes,
24
+ } from './internal.multipartcopy.js';
21
25
  import {
22
26
  getProviderStatus,
23
27
  headBucketExists,
@@ -930,8 +934,18 @@ export class Bucket {
930
934
  return purgeExactPathOperation(this, optionsArg);
931
935
  }
932
936
 
937
+ /**
938
+ * Copies an object within the storage account. Sources up to 5 GiB are
939
+ * copied with one CopyObject; larger sources, which CopyObject refuses, are
940
+ * copied part by part with UploadPartCopy, each part conditional on the
941
+ * source ETag read first.
942
+ * A failed or aborted part copy aborts the multipart upload. Object tags,
943
+ * storage class, server-side encryption settings and a website redirect are
944
+ * not carried by a part-by-part copy.
945
+ */
933
946
  public async fastCopy(optionsArg: interfaces.IFastCopyOptions): Promise<void> {
934
947
  const operation = beginBasicBucketOperation(this, optionsArg.signal);
948
+ const deferredCleanups: Promise<void>[] = [];
935
949
  try {
936
950
  const targetBucketName = optionsArg.targetBucket ? optionsArg.targetBucket.name : this.name;
937
951
 
@@ -951,10 +965,41 @@ export class Bucket {
951
965
 
952
966
  // Define the copy operation
953
967
  const copySource = `${operation.bucketName}/${optionsArg.sourcePath}`;
968
+ const targetKey = optionsArg.destinationPath || optionsArg.sourcePath;
969
+ const sourceSize = currentObjInfo.ContentLength;
970
+ if (typeof sourceSize === 'number' && sourceSize > multipartCopyThresholdBytes) {
971
+ if (typeof currentObjInfo.ETag !== 'string' || currentObjInfo.ETag.length === 0) {
972
+ throw new Error('HeadObject returned no ETag for the copy source.');
973
+ }
974
+ await copyObjectMultipart(operation, {
975
+ copySource,
976
+ sourceSize,
977
+ sourceEtag: currentObjInfo.ETag,
978
+ targetBucketName,
979
+ targetKey,
980
+ // Mirrors CopyObject: COPY keeps the source metadata and content
981
+ // headers, REPLACE sets only the given metadata.
982
+ objectProperties: optionsArg.deleteExistingNativeMetadata
983
+ ? { Metadata: newNativeMetadata }
984
+ : {
985
+ Metadata: currentObjInfo.Metadata,
986
+ ContentType: currentObjInfo.ContentType,
987
+ CacheControl: currentObjInfo.CacheControl,
988
+ ContentDisposition: currentObjInfo.ContentDisposition,
989
+ ContentEncoding: currentObjInfo.ContentEncoding,
990
+ ContentLanguage: currentObjInfo.ContentLanguage,
991
+ Expires: currentObjInfo.Expires,
992
+ },
993
+ onDeferredCleanup: (settlementArg) => {
994
+ deferredCleanups.push(settlementArg);
995
+ },
996
+ });
997
+ return;
998
+ }
954
999
  const command = new plugins.s3.CopyObjectCommand({
955
1000
  Bucket: targetBucketName,
956
1001
  CopySource: copySource,
957
- Key: optionsArg.destinationPath || optionsArg.sourcePath,
1002
+ Key: targetKey,
958
1003
  Metadata: newNativeMetadata,
959
1004
  MetadataDirective: optionsArg.deleteExistingNativeMetadata ? 'REPLACE' : 'COPY',
960
1005
  });
@@ -962,7 +1007,11 @@ export class Bucket {
962
1007
  () => operation.client.send(command, { abortSignal: operation.signal }),
963
1008
  );
964
1009
  } finally {
965
- operation.finish();
1010
+ if (deferredCleanups.length > 0) {
1011
+ void Promise.allSettled(deferredCleanups).then(() => operation.finish());
1012
+ } else {
1013
+ operation.finish();
1014
+ }
966
1015
  }
967
1016
  }
968
1017
 
@@ -264,7 +264,18 @@ export interface IFastPutStreamExactOptions {
264
264
  * size (5 MiB by default).
265
265
  */
266
266
  readableStream: Readable | ReadableStream<Uint8Array>;
267
- contentLength: number;
267
+ /**
268
+ * The exact source length. Exactly one of `contentLength` and
269
+ * `maxContentLength` must be given.
270
+ */
271
+ contentLength?: number;
272
+ /**
273
+ * The upper bound for a source of undeclared length. The part size grows so
274
+ * that the maximum fits in 10,000 parts, and buffering stays bounded to the
275
+ * planned parts in flight. A source that exceeds the maximum fails the
276
+ * upload, and the multipart upload is aborted instead of completed.
277
+ */
278
+ maxContentLength?: number;
268
279
  nativeMetadata?: Record<string, string>;
269
280
  /**
270
281
  * Native S3 object properties that must retain their semantic header
@@ -316,7 +327,10 @@ export type TExactUploadMultipartCleanup =
316
327
  export interface IExactUploadFailureState {
317
328
  phase: TExactUploadFailurePhase;
318
329
  destinationPath: string;
319
- expectedContentLength: number;
330
+ /** The declared source length, when the upload declared one. */
331
+ expectedContentLength?: number;
332
+ /** The source maximum, when the upload had an undeclared length. */
333
+ maxContentLength?: number;
320
334
  /**
321
335
  * Whether this operation can prove that it did not publish the destination.
322
336
  * A conditional PutObject or CompleteMultipartUpload response can be lost
@@ -15,7 +15,10 @@ import {
15
15
  mintExactUploadCapability,
16
16
  type IExactUploadOperation,
17
17
  } from './internal.exactupload.capability.js';
18
- import { planMultipartUpload } from './internal.multipart.planner.js';
18
+ import {
19
+ planBoundedMultipartUpload,
20
+ planMultipartUpload,
21
+ } from './internal.multipart.planner.js';
19
22
  import {
20
23
  consumeExactMultipartStream,
21
24
  MultipartStreamConsumptionError,
@@ -44,7 +47,7 @@ interface IIndependentCleanupResult {
44
47
  settlement: Promise<void>;
45
48
  }
46
49
 
47
- interface IMultipartAbortResult {
50
+ export interface IMultipartAbortResult {
48
51
  cleanup: TExactUploadMultipartCleanup;
49
52
  timedOut: boolean;
50
53
  settlement: Promise<void>;
@@ -1263,10 +1266,11 @@ export const probeExactUploadCapability = async (
1263
1266
  }
1264
1267
  };
1265
1268
 
1266
- const abortMultipartUpload = async (
1269
+ export const abortMultipartUpload = async (
1267
1270
  operationArg: IExactUploadOperation,
1268
1271
  keyArg: string,
1269
1272
  uploadIdArg: string,
1273
+ bucketNameArg = operationArg.bucketName,
1270
1274
  ): Promise<IMultipartAbortResult> => {
1271
1275
  let reconciliationResult: 'aborted' | 'absent' | undefined;
1272
1276
  const cleanupResult = await runIndependentCleanup(
@@ -1274,7 +1278,7 @@ const abortMultipartUpload = async (
1274
1278
  async (signalArg) => {
1275
1279
  reconciliationResult = await reconcileMultipartUploadAbort(
1276
1280
  operationArg.client,
1277
- operationArg.bucketName,
1281
+ bucketNameArg,
1278
1282
  keyArg,
1279
1283
  uploadIdArg,
1280
1284
  signalArg,
@@ -1306,6 +1310,69 @@ const abortMultipartUpload = async (
1306
1310
  };
1307
1311
  };
1308
1312
 
1313
+ /**
1314
+ * Plans the upload from its declared length, or from the maximum of a source
1315
+ * of undeclared length, and names that length for failure states.
1316
+ */
1317
+ const planExactUpload = (optionsArg: IFastPutStreamExactOptions) => {
1318
+ const declaresLength = optionsArg.contentLength !== undefined;
1319
+ if (declaresLength === (optionsArg.maxContentLength !== undefined)) {
1320
+ throw new TypeError(
1321
+ 'Exactly one of contentLength and maxContentLength must be given.',
1322
+ );
1323
+ }
1324
+ if (declaresLength) {
1325
+ return {
1326
+ plan: planMultipartUpload({
1327
+ objectSize: optionsArg.contentLength!,
1328
+ requestedPartSize: optionsArg.requestedPartSize,
1329
+ requestedConcurrency: optionsArg.requestedConcurrency,
1330
+ }),
1331
+ lengthState: {
1332
+ expectedContentLength: optionsArg.contentLength,
1333
+ },
1334
+ };
1335
+ }
1336
+ return {
1337
+ plan: planBoundedMultipartUpload({
1338
+ maxObjectSize: optionsArg.maxContentLength!,
1339
+ requestedPartSize: optionsArg.requestedPartSize,
1340
+ requestedConcurrency: optionsArg.requestedConcurrency,
1341
+ }),
1342
+ lengthState: {
1343
+ maxContentLength: optionsArg.maxContentLength,
1344
+ },
1345
+ };
1346
+ };
1347
+
1348
+ const sendConditionalZeroPut = (
1349
+ operationArg: IExactUploadOperation,
1350
+ optionsArg: IFastPutStreamExactOptions,
1351
+ contentTypeArg: string | undefined,
1352
+ onAttemptArg: () => void,
1353
+ ) => {
1354
+ return sendOperationCommand(
1355
+ operationArg,
1356
+ (signalArg) => {
1357
+ onAttemptArg();
1358
+ return operationArg.client.send(
1359
+ new plugins.s3.PutObjectCommand({
1360
+ Bucket: operationArg.bucketName,
1361
+ Key: optionsArg.path,
1362
+ Body: new Uint8Array(0),
1363
+ ContentLength: 0,
1364
+ Metadata: optionsArg.nativeMetadata,
1365
+ ContentType: contentTypeArg,
1366
+ IfNoneMatch: '*',
1367
+ }),
1368
+ {
1369
+ abortSignal: signalArg,
1370
+ },
1371
+ );
1372
+ },
1373
+ );
1374
+ };
1375
+
1309
1376
  export const fastPutStreamExact = async (
1310
1377
  bucketRefArg: IExactUploadBucketRef,
1311
1378
  optionsArg: IFastPutStreamExactOptions,
@@ -1316,11 +1383,7 @@ export const fastPutStreamExact = async (
1316
1383
  const nativeObjectProperties = snapshotNativeObjectProperties(
1317
1384
  optionsArg.nativeObjectProperties,
1318
1385
  );
1319
- const plan = planMultipartUpload({
1320
- objectSize: optionsArg.contentLength,
1321
- requestedPartSize: optionsArg.requestedPartSize,
1322
- requestedConcurrency: optionsArg.requestedConcurrency,
1323
- });
1386
+ const { plan, lengthState } = planExactUpload(optionsArg);
1324
1387
 
1325
1388
  let operation: IExactUploadOperation;
1326
1389
  try {
@@ -1334,7 +1397,7 @@ export const fastPutStreamExact = async (
1334
1397
  {
1335
1398
  phase: 'capability',
1336
1399
  destinationPath: optionsArg.path,
1337
- expectedContentLength: optionsArg.contentLength,
1400
+ ...lengthState,
1338
1401
  destinationPublication: 'not-published',
1339
1402
  multipartCleanup: {
1340
1403
  status: 'not-required',
@@ -1353,7 +1416,7 @@ export const fastPutStreamExact = async (
1353
1416
  {
1354
1417
  phase: 'source',
1355
1418
  destinationPath: optionsArg.path,
1356
- expectedContentLength: optionsArg.contentLength,
1419
+ ...lengthState,
1357
1420
  destinationPublication: 'not-published',
1358
1421
  multipartCleanup: {
1359
1422
  status: 'not-required',
@@ -1393,7 +1456,7 @@ export const fastPutStreamExact = async (
1393
1456
  {
1394
1457
  phase: 'source',
1395
1458
  destinationPath: optionsArg.path,
1396
- expectedContentLength: optionsArg.contentLength,
1459
+ ...lengthState,
1397
1460
  destinationPublication: 'not-published',
1398
1461
  multipartCleanup: {
1399
1462
  status: 'not-required',
@@ -1404,24 +1467,12 @@ export const fastPutStreamExact = async (
1404
1467
  }
1405
1468
 
1406
1469
  try {
1407
- const putOutput = await sendOperationCommand(
1470
+ const putOutput = await sendConditionalZeroPut(
1408
1471
  operation,
1409
- (signalArg) => {
1472
+ optionsArg,
1473
+ nativeObjectProperties?.contentType,
1474
+ () => {
1410
1475
  putRequestAttempted = true;
1411
- return operation.client.send(
1412
- new plugins.s3.PutObjectCommand({
1413
- Bucket: operation.bucketName,
1414
- Key: optionsArg.path,
1415
- Body: new Uint8Array(0),
1416
- ContentLength: 0,
1417
- Metadata: optionsArg.nativeMetadata,
1418
- ContentType: nativeObjectProperties?.contentType,
1419
- IfNoneMatch: '*',
1420
- }),
1421
- {
1422
- abortSignal: signalArg,
1423
- },
1424
- );
1425
1476
  },
1426
1477
  );
1427
1478
  return {
@@ -1441,7 +1492,7 @@ export const fastPutStreamExact = async (
1441
1492
  {
1442
1493
  phase: 'put-zero',
1443
1494
  destinationPath: optionsArg.path,
1444
- expectedContentLength: optionsArg.contentLength,
1495
+ ...lengthState,
1445
1496
  destinationPublication:
1446
1497
  putRequestAttempted &&
1447
1498
  !isDefinitiveConditionalWriteRejection(error)
@@ -1465,36 +1516,64 @@ export const fastPutStreamExact = async (
1465
1516
  }
1466
1517
  }
1467
1518
 
1468
- let phase: TExactUploadFailurePhase = 'create-multipart';
1519
+ const bounded = plan.classification === 'bounded';
1520
+ let phase: TExactUploadFailurePhase = bounded ? 'source' : 'create-multipart';
1469
1521
  let uploadId: string | undefined;
1470
1522
  let createRequestAttempted = false;
1523
+ let createFailed = false;
1524
+ let createSettled = false;
1525
+ let zeroPutAttempted = false;
1471
1526
  let completeRequestAttempted = false;
1472
1527
  let operationFinishSettlement: Promise<void> | undefined;
1473
1528
  let detachedStreamError: MultipartStreamConsumptionError | undefined;
1474
- try {
1475
- const createOutput = await sendOperationCommand(
1529
+ let multipartUploadCreation: Promise<string> | undefined;
1530
+ // A source of undeclared length creates the multipart upload with its first
1531
+ // part, so an empty source never leaves one behind.
1532
+ const ensureMultipartUpload = (): Promise<string> => {
1533
+ multipartUploadCreation ??= sendOperationCommand(
1476
1534
  operation,
1477
1535
  (signalArg) => {
1478
1536
  createRequestAttempted = true;
1479
- return operation.client.send(
1480
- new plugins.s3.CreateMultipartUploadCommand({
1481
- Bucket: operation.bucketName,
1482
- Key: optionsArg.path,
1483
- Metadata: optionsArg.nativeMetadata,
1484
- ContentType: nativeObjectProperties?.contentType,
1485
- }),
1486
- {
1487
- abortSignal: signalArg,
1488
- },
1537
+ return operation.client
1538
+ .send(
1539
+ new plugins.s3.CreateMultipartUploadCommand({
1540
+ Bucket: operation.bucketName,
1541
+ Key: optionsArg.path,
1542
+ Metadata: optionsArg.nativeMetadata,
1543
+ ContentType: nativeObjectProperties?.contentType,
1544
+ }),
1545
+ {
1546
+ abortSignal: signalArg,
1547
+ },
1548
+ )
1549
+ .then((createOutputArg) => {
1550
+ // A create that ignored cancellation still owns its upload, so
1551
+ // deferred cleanup aborts it by this id.
1552
+ uploadId = getOptionalNonEmptyString(createOutputArg.UploadId);
1553
+ return createOutputArg;
1554
+ });
1555
+ },
1556
+ ).then(
1557
+ (createOutputArg) => {
1558
+ createSettled = true;
1559
+ return requireNonEmptyString(
1560
+ createOutputArg.UploadId,
1561
+ 'CreateMultipartUpload UploadId',
1489
1562
  );
1490
1563
  },
1564
+ (error: unknown) => {
1565
+ createSettled = true;
1566
+ createFailed = true;
1567
+ throw error;
1568
+ },
1491
1569
  );
1492
- uploadId = requireNonEmptyString(
1493
- createOutput.UploadId,
1494
- 'CreateMultipartUpload UploadId',
1495
- );
1496
-
1497
- phase = 'source';
1570
+ return multipartUploadCreation;
1571
+ };
1572
+ try {
1573
+ if (!bounded) {
1574
+ await ensureMultipartUpload();
1575
+ phase = 'source';
1576
+ }
1498
1577
  const completedParts: plugins.s3.CompletedPart[] = [];
1499
1578
  let consumptionResult;
1500
1579
  try {
@@ -1505,12 +1584,14 @@ export const fastPutStreamExact = async (
1505
1584
  failureSettlementTimeoutMs:
1506
1585
  optionsArg.failureSettlementTimeoutMs,
1507
1586
  async onPart(partArg) {
1587
+ operation.assertCurrent();
1588
+ const partUploadId = await ensureMultipartUpload();
1508
1589
  operation.assertCurrent();
1509
1590
  const uploadPartOutput = await operation.client.send(
1510
1591
  new plugins.s3.UploadPartCommand({
1511
1592
  Bucket: operation.bucketName,
1512
1593
  Key: optionsArg.path,
1513
- UploadId: uploadId!,
1594
+ UploadId: partUploadId,
1514
1595
  PartNumber: partArg.partNumber,
1515
1596
  Body: partArg.data,
1516
1597
  ContentLength: partArg.data.byteLength,
@@ -1532,9 +1613,15 @@ export const fastPutStreamExact = async (
1532
1613
  } catch (error) {
1533
1614
  if (
1534
1615
  error instanceof MultipartStreamConsumptionError &&
1535
- error.state.kind === 'part-callback'
1616
+ (error.state.kind === 'part-callback' || error.state.kind === 'aborted')
1536
1617
  ) {
1537
- phase = 'upload-part';
1618
+ // A source of undeclared length creates its upload with the first
1619
+ // part, so a failed or still outstanding create owns the failure.
1620
+ if (createFailed || (multipartUploadCreation && !createSettled)) {
1621
+ phase = 'create-multipart';
1622
+ } else if (error.state.kind === 'part-callback') {
1623
+ phase = 'upload-part';
1624
+ }
1538
1625
  }
1539
1626
  if (
1540
1627
  error instanceof MultipartStreamConsumptionError &&
@@ -1548,6 +1635,28 @@ export const fastPutStreamExact = async (
1548
1635
  throw error;
1549
1636
  }
1550
1637
 
1638
+ if (!multipartUploadCreation) {
1639
+ // Only an empty source of undeclared length reaches this point.
1640
+ phase = 'put-zero';
1641
+ const putOutput = await sendConditionalZeroPut(
1642
+ operation,
1643
+ optionsArg,
1644
+ nativeObjectProperties?.contentType,
1645
+ () => {
1646
+ zeroPutAttempted = true;
1647
+ },
1648
+ );
1649
+ return {
1650
+ destinationPath: optionsArg.path,
1651
+ contentLength: 0,
1652
+ etag: requireNonEmptyString(putOutput.ETag, 'PutObject ETag'),
1653
+ versionId: getOptionalNonEmptyString(putOutput.VersionId),
1654
+ partCount: 0,
1655
+ peakBufferedBytes: consumptionResult.peakBufferedBytes,
1656
+ maximumBufferedBytes: consumptionResult.maximumBufferedBytes,
1657
+ };
1658
+ }
1659
+ const completeUploadId = await multipartUploadCreation;
1551
1660
  phase = 'complete-multipart';
1552
1661
  const completeOutput = await sendOperationCommand(
1553
1662
  operation,
@@ -1557,12 +1666,12 @@ export const fastPutStreamExact = async (
1557
1666
  new plugins.s3.CompleteMultipartUploadCommand({
1558
1667
  Bucket: operation.bucketName,
1559
1668
  Key: optionsArg.path,
1560
- UploadId: uploadId,
1669
+ UploadId: completeUploadId,
1561
1670
  MultipartUpload: {
1562
1671
  Parts: completedParts,
1563
1672
  },
1564
1673
  IfNoneMatch: '*',
1565
- MpuObjectSize: optionsArg.contentLength,
1674
+ MpuObjectSize: consumptionResult.bytesConsumed,
1566
1675
  }),
1567
1676
  {
1568
1677
  abortSignal: signalArg,
@@ -1572,7 +1681,7 @@ export const fastPutStreamExact = async (
1572
1681
  );
1573
1682
  return {
1574
1683
  destinationPath: optionsArg.path,
1575
- contentLength: optionsArg.contentLength,
1684
+ contentLength: consumptionResult.bytesConsumed,
1576
1685
  etag: requireNonEmptyString(
1577
1686
  completeOutput.ETag,
1578
1687
  'CompleteMultipartUpload ETag',
@@ -1586,21 +1695,21 @@ export const fastPutStreamExact = async (
1586
1695
  let multipartCleanup: TExactUploadMultipartCleanup;
1587
1696
  const primaryProviderSettlement =
1588
1697
  getDetachedOperationSendSettlement(operation.signal);
1589
- if (
1590
- uploadId &&
1591
- detachedStreamError?.state.cleanup.callbacks === 'detached'
1592
- ) {
1593
- multipartCleanup = {
1594
- status: 'deferred',
1595
- };
1596
- const callbackGatedCleanup = detachedStreamError.callbackSettlement
1698
+ // Aborts the multipart upload once the given work settles. A create that
1699
+ // was still in flight may only report its UploadId by then.
1700
+ const abortAfterSettlement = (settlementArg: Promise<unknown>) =>
1701
+ settlementArg
1597
1702
  .then(async () => {
1703
+ await getDetachedOperationSendSettlement(operation.signal);
1704
+ if (!uploadId) {
1705
+ return;
1706
+ }
1598
1707
  try {
1599
1708
  operation.assertLifecycleCurrent();
1600
1709
  const abortResult = await abortMultipartUpload(
1601
1710
  operation,
1602
1711
  optionsArg.path,
1603
- uploadId!,
1712
+ uploadId,
1604
1713
  );
1605
1714
  await abortResult.settlement;
1606
1715
  } catch {
@@ -1611,35 +1720,27 @@ export const fastPutStreamExact = async (
1611
1720
  () => undefined,
1612
1721
  () => undefined,
1613
1722
  );
1723
+ if (
1724
+ (uploadId || createRequestAttempted) &&
1725
+ detachedStreamError?.state.cleanup.callbacks === 'detached'
1726
+ ) {
1727
+ multipartCleanup = {
1728
+ status: 'deferred',
1729
+ };
1614
1730
  operationFinishSettlement = Promise.allSettled([
1615
- callbackGatedCleanup,
1731
+ abortAfterSettlement(detachedStreamError.callbackSettlement),
1616
1732
  detachedStreamError.ownedSettlement,
1617
1733
  ]).then(() => undefined);
1618
- } else if (uploadId && primaryProviderSettlement) {
1734
+ } else if (
1735
+ (uploadId || createRequestAttempted) &&
1736
+ primaryProviderSettlement
1737
+ ) {
1619
1738
  multipartCleanup = {
1620
1739
  status: 'deferred',
1621
1740
  };
1622
- const providerGatedCleanup = primaryProviderSettlement
1623
- .then(async () => {
1624
- try {
1625
- operation.assertLifecycleCurrent();
1626
- const abortResult = await abortMultipartUpload(
1627
- operation,
1628
- optionsArg.path,
1629
- uploadId!,
1630
- );
1631
- await abortResult.settlement;
1632
- } catch {
1633
- // Lifecycle close makes deferred cleanup indeterminate.
1634
- }
1635
- })
1636
- .then(
1637
- () => undefined,
1638
- () => undefined,
1639
- );
1640
1741
  operationFinishSettlement = Promise.allSettled([
1641
1742
  primaryProviderSettlement,
1642
- providerGatedCleanup,
1743
+ abortAfterSettlement(primaryProviderSettlement),
1643
1744
  ]).then(() => undefined);
1644
1745
  } else if (uploadId) {
1645
1746
  const abortResult = await abortMultipartUpload(
@@ -1653,20 +1754,11 @@ export const fastPutStreamExact = async (
1653
1754
  }
1654
1755
  } else if (createRequestAttempted) {
1655
1756
  multipartCleanup = {
1656
- ...(primaryProviderSettlement
1657
- ? {
1658
- status: 'deferred' as const,
1659
- }
1660
- : {
1661
- status: 'abort-failed' as const,
1662
- cause: new Error(
1663
- 'The multipart upload could not be aborted because no UploadId was returned.',
1664
- ),
1665
- }),
1757
+ status: 'abort-failed',
1758
+ cause: new Error(
1759
+ 'The multipart upload could not be aborted because no UploadId was returned.',
1760
+ ),
1666
1761
  };
1667
- if (primaryProviderSettlement) {
1668
- operationFinishSettlement = primaryProviderSettlement;
1669
- }
1670
1762
  } else {
1671
1763
  multipartCleanup = {
1672
1764
  status: 'not-required',
@@ -1691,10 +1783,12 @@ export const fastPutStreamExact = async (
1691
1783
  {
1692
1784
  phase,
1693
1785
  destinationPath: optionsArg.path,
1694
- expectedContentLength: optionsArg.contentLength,
1786
+ ...lengthState,
1695
1787
  destinationPublication:
1696
- phase === 'complete-multipart' &&
1697
- completeRequestAttempted &&
1788
+ (
1789
+ (phase === 'complete-multipart' && completeRequestAttempted) ||
1790
+ (phase === 'put-zero' && zeroPutAttempted)
1791
+ ) &&
1698
1792
  !isDefinitiveConditionalWriteRejection(error)
1699
1793
  ? 'ambiguous'
1700
1794
  : 'not-published',
@@ -1,6 +1,8 @@
1
1
  import type {
2
+ IMultipartBoundedPlanningOptions,
3
+ IMultipartBoundedUploadPlan,
2
4
  IMultipartPlanningOptions,
3
- TMultipartUploadPlan,
5
+ TMultipartDeclaredUploadPlan,
4
6
  } from './internal.multipart.types.js';
5
7
 
6
8
  export const MULTIPART_MIN_PART_SIZE = 5 * 1024 * 1024;
@@ -43,7 +45,7 @@ const normalizeRequestedConcurrency = (
43
45
 
44
46
  export const planMultipartUpload = (
45
47
  optionsArg: IMultipartPlanningOptions,
46
- ): TMultipartUploadPlan => {
48
+ ): TMultipartDeclaredUploadPlan => {
47
49
  requireSafeNonNegativeInteger(optionsArg.objectSize, 'objectSize');
48
50
  if (optionsArg.objectSize > MULTIPART_MAX_OBJECT_SIZE) {
49
51
  throw new RangeError(
@@ -88,3 +90,45 @@ export const planMultipartUpload = (
88
90
  ) + Math.min(optionsArg.objectSize, partSize),
89
91
  };
90
92
  };
93
+
94
+ /**
95
+ * Plans an upload of undeclared length that must not exceed
96
+ * `maxObjectSize`. The part size grows with the maximum so that the largest
97
+ * allowed source still fits into the multipart part limit.
98
+ */
99
+ export const planBoundedMultipartUpload = (
100
+ optionsArg: IMultipartBoundedPlanningOptions,
101
+ ): IMultipartBoundedUploadPlan => {
102
+ if (!Number.isSafeInteger(optionsArg.maxObjectSize) || optionsArg.maxObjectSize < 1) {
103
+ throw new TypeError('maxObjectSize must be a positive safe integer.');
104
+ }
105
+ if (optionsArg.maxObjectSize > MULTIPART_MAX_OBJECT_SIZE) {
106
+ throw new RangeError(
107
+ `maxObjectSize exceeds the ${MULTIPART_MAX_OBJECT_SIZE} byte multipart limit.`,
108
+ );
109
+ }
110
+ const requestedConcurrency = normalizeRequestedConcurrency(
111
+ optionsArg.requestedConcurrency,
112
+ );
113
+ const preferredPartSize = normalizeRequestedPartSize(optionsArg.requestedPartSize);
114
+ const partSize = Math.max(
115
+ preferredPartSize,
116
+ Math.ceil(optionsArg.maxObjectSize / MULTIPART_MAX_PART_COUNT),
117
+ );
118
+ if (partSize > MULTIPART_MAX_PART_SIZE) {
119
+ throw new RangeError('The maximum cannot be represented by legal multipart parts.');
120
+ }
121
+ const maxPartCount = Math.ceil(optionsArg.maxObjectSize / partSize);
122
+ const concurrency = Math.min(requestedConcurrency, maxPartCount);
123
+ return {
124
+ classification: 'bounded',
125
+ maxObjectSize: optionsArg.maxObjectSize,
126
+ partSize,
127
+ maxPartCount,
128
+ concurrency,
129
+ maximumBufferedBytes: Math.min(
130
+ optionsArg.maxObjectSize,
131
+ partSize * concurrency,
132
+ ) + Math.min(optionsArg.maxObjectSize, partSize),
133
+ };
134
+ };