@cloudbitmaps/s3 0.11.2 → 0.12.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/index.js CHANGED
@@ -1,18 +1,18 @@
1
1
  // src/backend.ts
2
- import { ValidationError as ValidationError3, brandAsBackend } from "@cloudbitmaps/core/driver-kit";
2
+ import { ValidationError as ValidationError5, brandAsBackend } from "@cloudbitmaps/core/driver-kit";
3
3
  import { S3Client } from "@aws-sdk/client-s3";
4
4
 
5
5
  // src/storage.ts
6
6
  import {
7
7
  NotFoundError,
8
- TransientError,
9
- ValidationError as ValidationError2,
8
+ TransientError as TransientError2,
9
+ ValidationError as ValidationError3,
10
10
  WriteConflictError,
11
11
  isNotFoundError,
12
12
  isValidationError,
13
13
  isWriteConflictError
14
14
  } from "@cloudbitmaps/core/driver-kit";
15
- import { createHash } from "node:crypto";
15
+ import { createHash, randomBytes } from "node:crypto";
16
16
  import {
17
17
  AbortMultipartUploadCommand,
18
18
  CompleteMultipartUploadCommand,
@@ -100,6 +100,12 @@ function isPreconditionFailed(err) {
100
100
  function isConditionalConflict(err) {
101
101
  return isPreconditionFailed(err) || errorName(err) === "ConditionalRequestConflict" || httpStatus(err) === 409;
102
102
  }
103
+ function isThrottle(err) {
104
+ return errorName(err) === "SlowDown" || httpStatus(err) === 503;
105
+ }
106
+ function isNoSuchUpload(err) {
107
+ return errorName(err) === "NoSuchUpload";
108
+ }
103
109
  function isNotFound(err) {
104
110
  const name = errorName(err);
105
111
  return name === "NoSuchKey" || name === "NotFound" || httpStatus(err) === 404;
@@ -123,6 +129,43 @@ function totalFromContentRange(contentRange) {
123
129
  return Number.isSafeInteger(total) ? total : void 0;
124
130
  }
125
131
 
132
+ // src/read-timeout.ts
133
+ import { TransientError, ValidationError as ValidationError2 } from "@cloudbitmaps/core/driver-kit";
134
+ var DEFAULT_READ_TIMEOUT_MS = 0;
135
+ var MAX_TIMER_MS = 2147483647;
136
+ function resolveReadTimeoutMs(value) {
137
+ if (value === void 0) return DEFAULT_READ_TIMEOUT_MS;
138
+ if (!Number.isSafeInteger(value) || value < 0 || value > MAX_TIMER_MS) {
139
+ throw new ValidationError2(
140
+ `readTimeoutMs must be a non-negative safe integer no larger than ${MAX_TIMER_MS}; got ${describe(value)}`
141
+ );
142
+ }
143
+ return value;
144
+ }
145
+ function describe(value) {
146
+ if (typeof value === "string") return JSON.stringify(value);
147
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
148
+ return String(value);
149
+ }
150
+ return value === null ? "null" : typeof value;
151
+ }
152
+ async function timedRead(operation, timeoutMs, read) {
153
+ if (timeoutMs === 0) return read(void 0);
154
+ const controller = new AbortController();
155
+ let timer;
156
+ const timedOut = new Promise((_, reject) => {
157
+ timer = setTimeout(() => {
158
+ reject(new TransientError(`S3 ${operation} timed out after ${timeoutMs} ms`));
159
+ controller.abort();
160
+ }, timeoutMs);
161
+ });
162
+ try {
163
+ return await Promise.race([read({ abortSignal: controller.signal }), timedOut]);
164
+ } finally {
165
+ clearTimeout(timer);
166
+ }
167
+ }
168
+
126
169
  // src/send-once.ts
127
170
  var NO_RETRY = {
128
171
  name: "retryMiddleware",
@@ -138,27 +181,37 @@ function sendOnce(client, command) {
138
181
  // src/storage.ts
139
182
  var S3_PART_BYTES = 8 * 1024 * 1024;
140
183
  var S3_MAX_PARTS = 1e4;
184
+ var WRITE_ID_KEY = "cbwid";
185
+ var THROTTLE_RESENDS = 3;
186
+ var THROTTLE_BASE_DELAY_MS = 500;
187
+ var REAL_TIME = {
188
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
189
+ };
141
190
  var S3StorageDriver = class {
142
191
  client;
143
192
  bucket;
144
193
  prefix;
145
194
  maxObjectBytes;
146
195
  partBytes;
196
+ readTimeoutMs;
197
+ clock;
147
198
  constructor(options) {
148
199
  this.client = options.client;
149
200
  this.bucket = options.bucket;
150
201
  this.prefix = normalizeObjectPrefix(options.prefix);
202
+ this.clock = options.clock ?? REAL_TIME;
151
203
  for (const [name, value] of [
152
204
  ["partBytes", options.partBytes],
153
205
  ["maxObjectBytes", options.maxObjectBytes]
154
206
  ]) {
155
207
  if (value !== void 0 && (!Number.isSafeInteger(value) || value < 1)) {
156
- throw new ValidationError2(`${name} must be a positive safe integer; got ${value}`);
208
+ throw new ValidationError3(`${name} must be a positive safe integer; got ${value}`);
157
209
  }
158
210
  }
159
211
  const requestedPart = Math.max(options.partBytes ?? S3_PART_BYTES, 5 * 1024 * 1024);
160
212
  this.maxObjectBytes = options.maxObjectBytes ?? requestedPart * S3_MAX_PARTS;
161
213
  this.partBytes = Math.max(requestedPart, Math.ceil(this.maxObjectBytes / S3_MAX_PARTS));
214
+ this.readTimeoutMs = resolveReadTimeoutMs(options.readTimeoutMs);
162
215
  }
163
216
  capabilities() {
164
217
  return { rangeRead: true, maxObjectBytes: this.maxObjectBytes, conditionalPut: true };
@@ -170,7 +223,9 @@ var S3StorageDriver = class {
170
223
  this.bucket,
171
224
  objectKey,
172
225
  this.partBytes,
173
- this.maxObjectBytes
226
+ this.maxObjectBytes,
227
+ this.readTimeoutMs,
228
+ this.clock
174
229
  );
175
230
  try {
176
231
  await write(sink);
@@ -190,61 +245,68 @@ var S3StorageDriver = class {
190
245
  }
191
246
  async getRange(key, offset, length) {
192
247
  if (!Number.isInteger(offset) || !Number.isInteger(length) || offset < 0 || length < 0) {
193
- throw new ValidationError2(`invalid range offset=${offset} length=${length}`);
248
+ throw new ValidationError3(`invalid range offset=${offset} length=${length}`);
194
249
  }
195
250
  const objectKey = storageObjectKey(this.prefix, key);
196
251
  if (length === 0) return new Uint8Array(0);
197
- try {
252
+ return this.read("GetObject", key, async (options) => {
198
253
  const res = await this.client.send(
199
254
  new GetObjectCommand({
200
255
  Bucket: this.bucket,
201
256
  Key: objectKey,
202
257
  Range: `bytes=${offset}-${offset + length - 1}`
203
- })
258
+ }),
259
+ options
204
260
  );
205
261
  const bytes = await collect(res.Body);
206
262
  if (bytes.length !== length) {
207
- throw new ValidationError2(
263
+ throw new ValidationError3(
208
264
  `range [${offset}, ${offset + length}) out of bounds (got ${bytes.length}B)`
209
265
  );
210
266
  }
211
267
  return bytes;
212
- } catch (err) {
213
- throw this.mapReadError(err, key);
214
- }
268
+ });
215
269
  }
216
270
  async getTail(key, maxBytes) {
217
271
  const objectKey = storageObjectKey(this.prefix, key);
218
272
  if (maxBytes <= 0) {
219
- try {
220
- const head = await this.client.send(
221
- new HeadObjectCommand({ Bucket: this.bucket, Key: objectKey })
222
- );
223
- return { bytes: new Uint8Array(0), size: head.ContentLength ?? 0 };
224
- } catch (err) {
225
- throw this.mapReadError(err, key);
226
- }
273
+ return { bytes: new Uint8Array(0), size: await this.headSize(key, objectKey) ?? 0 };
227
274
  }
228
- try {
275
+ const { bytes, contentRange } = await this.read("GetObject", key, async (options) => {
229
276
  const res = await this.client.send(
230
- new GetObjectCommand({ Bucket: this.bucket, Key: objectKey, Range: `bytes=-${maxBytes}` })
277
+ new GetObjectCommand({ Bucket: this.bucket, Key: objectKey, Range: `bytes=-${maxBytes}` }),
278
+ options
231
279
  );
232
- const bytes = await collect(res.Body);
233
- let size = totalFromContentRange(res.ContentRange);
234
- if (size === void 0) {
235
- if (bytes.length === maxBytes) {
236
- const head = await this.client.send(
237
- new HeadObjectCommand({ Bucket: this.bucket, Key: objectKey })
238
- );
239
- size = head.ContentLength ?? bytes.length;
240
- } else {
241
- size = bytes.length;
242
- }
243
- }
244
- return { bytes, size };
245
- } catch (err) {
246
- throw this.mapReadError(err, key);
280
+ return { bytes: await collect(res.Body), contentRange: res.ContentRange };
281
+ });
282
+ let size = totalFromContentRange(contentRange);
283
+ if (size === void 0) {
284
+ size = bytes.length === maxBytes ? await this.headSize(key, objectKey) ?? bytes.length : bytes.length;
247
285
  }
286
+ return { bytes, size };
287
+ }
288
+ /** The object's size from a `HeadObject`, or `undefined` when the response does not carry one. */
289
+ headSize(key, objectKey) {
290
+ return this.read("HeadObject", key, async (options) => {
291
+ const head = await this.client.send(
292
+ new HeadObjectCommand({ Bucket: this.bucket, Key: objectKey }),
293
+ options
294
+ );
295
+ return head.ContentLength;
296
+ });
297
+ }
298
+ /**
299
+ * One read request under the read timeout, its failures mapped to the driver vocabulary. The timeout's own
300
+ * `TransientError` is raised outside the mapping, so it reaches the caller as it is.
301
+ */
302
+ read(operation, key, run) {
303
+ return timedRead(operation, this.readTimeoutMs, async (options) => {
304
+ try {
305
+ return await run(options);
306
+ } catch (err) {
307
+ throw this.mapReadError(err, key);
308
+ }
309
+ });
248
310
  }
249
311
  async delete(key) {
250
312
  try {
@@ -288,7 +350,7 @@ var S3StorageDriver = class {
288
350
  return new NotFoundError(`no such generation: ${key.segment}.${key.generation}`);
289
351
  }
290
352
  if (isInvalidRange(err)) {
291
- return new ValidationError2(`range out of bounds for ${key.segment}.${key.generation}`);
353
+ return new ValidationError3(`range out of bounds for ${key.segment}.${key.generation}`);
292
354
  }
293
355
  return this.mapError(err);
294
356
  }
@@ -300,7 +362,7 @@ var S3StorageDriver = class {
300
362
  */
301
363
  mapError(err) {
302
364
  if (isTransient(err)) {
303
- return new TransientError(
365
+ return new TransientError2(
304
366
  `transient S3 fault: ${err?.name ?? "unknown"}`,
305
367
  { cause: err }
306
368
  );
@@ -318,18 +380,22 @@ function concatBytes(parts, total) {
318
380
  return out;
319
381
  }
320
382
  var S3MultipartSink = class {
321
- constructor(client, bucket, objectKey, partBytes, maxObjectBytes) {
383
+ constructor(client, bucket, objectKey, partBytes, maxObjectBytes, readTimeoutMs, clock) {
322
384
  this.client = client;
323
385
  this.bucket = bucket;
324
386
  this.objectKey = objectKey;
325
387
  this.partBytes = partBytes;
326
388
  this.maxObjectBytes = maxObjectBytes;
389
+ this.readTimeoutMs = readTimeoutMs;
390
+ this.clock = clock;
327
391
  }
328
392
  client;
329
393
  bucket;
330
394
  objectKey;
331
395
  partBytes;
332
396
  maxObjectBytes;
397
+ readTimeoutMs;
398
+ clock;
333
399
  hash = createHash("sha256");
334
400
  pending = [];
335
401
  pendingLen = 0;
@@ -337,11 +403,13 @@ var S3MultipartSink = class {
337
403
  uploadId;
338
404
  partNumber = 0;
339
405
  parts = [];
406
+ /** This write's own id, stored in the object's user metadata, outside the `.crbm` bytes: see {@link commit}. */
407
+ writeId = randomBytes(16).toString("hex");
340
408
  async write(bytes) {
341
409
  if (bytes.length === 0) return;
342
410
  this.total += bytes.length;
343
411
  if (this.total > this.maxObjectBytes) {
344
- throw new ValidationError2(`object exceeds maxObjectBytes ${this.maxObjectBytes}`);
412
+ throw new ValidationError3(`object exceeds maxObjectBytes ${this.maxObjectBytes}`);
345
413
  }
346
414
  this.hash.update(bytes);
347
415
  this.pending.push(bytes);
@@ -352,10 +420,15 @@ var S3MultipartSink = class {
352
420
  async flushPart() {
353
421
  if (this.uploadId === void 0) {
354
422
  const res2 = await this.client.send(
355
- new CreateMultipartUploadCommand({ Bucket: this.bucket, Key: this.objectKey })
423
+ new CreateMultipartUploadCommand({
424
+ Bucket: this.bucket,
425
+ Key: this.objectKey,
426
+ Metadata: { [WRITE_ID_KEY]: this.writeId }
427
+ // stored on the object the completion makes
428
+ })
356
429
  );
357
430
  if (res2.UploadId === void 0) {
358
- throw new TransientError("S3 CreateMultipartUpload returned no UploadId");
431
+ throw new TransientError2("S3 CreateMultipartUpload returned no UploadId");
359
432
  }
360
433
  this.uploadId = res2.UploadId;
361
434
  }
@@ -364,7 +437,7 @@ var S3MultipartSink = class {
364
437
  this.pendingLen = 0;
365
438
  this.partNumber += 1;
366
439
  if (this.partNumber > S3_MAX_PARTS) {
367
- throw new ValidationError2(`multipart upload exceeded the S3 ${S3_MAX_PARTS}-part limit`);
440
+ throw new ValidationError3(`multipart upload exceeded the S3 ${S3_MAX_PARTS}-part limit`);
368
441
  }
369
442
  const res = await this.client.send(
370
443
  new UploadPartCommand({
@@ -381,35 +454,107 @@ var S3MultipartSink = class {
381
454
  async finish() {
382
455
  const sha256 = this.hash.digest("hex");
383
456
  if (this.uploadId === void 0) {
384
- await sendOnce(
457
+ const body = concatBytes(this.pending, this.pendingLen);
458
+ await this.commit(
459
+ "PutObject",
460
+ () => sendOnce(
461
+ this.client,
462
+ new PutObjectCommand({
463
+ Bucket: this.bucket,
464
+ Key: this.objectKey,
465
+ Body: body,
466
+ IfNoneMatch: "*",
467
+ // write-once
468
+ Metadata: { [WRITE_ID_KEY]: this.writeId }
469
+ })
470
+ )
471
+ );
472
+ return { size: this.total, sha256 };
473
+ }
474
+ if (this.pendingLen > 0) await this.flushPart();
475
+ const uploadId = this.uploadId;
476
+ await this.commit(
477
+ "CompleteMultipartUpload",
478
+ () => sendOnce(
385
479
  this.client,
386
- new PutObjectCommand({
480
+ new CompleteMultipartUploadCommand({
387
481
  Bucket: this.bucket,
388
482
  Key: this.objectKey,
389
- Body: concatBytes(this.pending, this.pendingLen),
483
+ UploadId: uploadId,
484
+ MultipartUpload: { Parts: this.parts },
390
485
  IfNoneMatch: "*"
391
- // write-once
486
+ // write-once: fail if the object already exists
392
487
  })
393
- );
394
- return { size: this.total, sha256 };
395
- }
396
- if (this.pendingLen > 0) await this.flushPart();
397
- await sendOnce(
398
- this.client,
399
- new CompleteMultipartUploadCommand({
400
- Bucket: this.bucket,
401
- Key: this.objectKey,
402
- UploadId: this.uploadId,
403
- MultipartUpload: { Parts: this.parts },
404
- IfNoneMatch: "*"
405
- // write-once: fail if the object already exists
406
- })
488
+ )
407
489
  );
408
490
  this.uploadId = void 0;
409
491
  return { size: this.total, sha256 };
410
492
  }
493
+ /**
494
+ * Send the commit (`send` sends it once, with the SDK's retry off), and again after a throttle, up to
495
+ * {@link THROTTLE_RESENDS} times, waiting a full-jitter backoff before each. Any other failure is thrown at once.
496
+ * Once the commit has been sent again, an answer that can be an earlier send that landed or is still landing (a
497
+ * precondition failure, a concurrent-write conflict, or an upload S3 no longer knows) is settled by reading the
498
+ * object back. The object is never overwritten, so the id it holds says who wrote it:
499
+ *
500
+ * - this write's own id: a success;
501
+ * - another's id, or none (an object an earlier release wrote): the conflict it reads as;
502
+ * - nothing stored: a `412` still reads as the conflict it answered (an object was there, and is gone). A `409`
503
+ * and an unknown upload are an unknown outcome, since the first send may still be applying: {@link TransientError},
504
+ * and never a report that nothing was written.
505
+ *
506
+ * A failed read-back throws, and is neither. Exhausted, it throws {@link TransientError}; nothing is deleted.
507
+ */
508
+ async commit(operation, send) {
509
+ for (let resent = 0; ; resent++) {
510
+ try {
511
+ await send();
512
+ return;
513
+ } catch (err) {
514
+ if (resent > 0 && (isConditionalConflict(err) || isNoSuchUpload(err))) {
515
+ const stored = await this.storedObject();
516
+ if (stored?.id === this.writeId) return;
517
+ if (stored === void 0 && !isPreconditionFailed(err)) {
518
+ throw new TransientError2(
519
+ `S3 ${operation}: ${this.objectKey} is not stored, and the first send may still be applying`,
520
+ { cause: err }
521
+ );
522
+ }
523
+ if (isConditionalConflict(err)) throw err;
524
+ throw new WriteConflictError(`generation already exists (write-once): ${this.objectKey}`);
525
+ }
526
+ if (!isThrottle(err)) throw err;
527
+ if (resent >= THROTTLE_RESENDS) {
528
+ throw new TransientError2(
529
+ `S3 ${operation} was throttled on each of its ${resent + 1} sends; the object may or may not exist`,
530
+ { cause: err }
531
+ );
532
+ }
533
+ await this.clock.sleep(Math.floor(Math.random() * THROTTLE_BASE_DELAY_MS * 2 ** resent));
534
+ }
535
+ }
536
+ }
537
+ /**
538
+ * The object stored under the key: `undefined` when none is there, otherwise the write id it carries (`id` is
539
+ * `undefined` for an object that carries none). A failed read throws.
540
+ */
541
+ storedObject() {
542
+ return timedRead("HeadObject", this.readTimeoutMs, async (options) => {
543
+ try {
544
+ const head = await this.client.send(
545
+ new HeadObjectCommand({ Bucket: this.bucket, Key: this.objectKey }),
546
+ options
547
+ );
548
+ return { id: head.Metadata?.[WRITE_ID_KEY] };
549
+ } catch (err) {
550
+ if (isNotFound(err)) return void 0;
551
+ throw err;
552
+ }
553
+ });
554
+ }
411
555
  /** Best-effort cleanup of an in-flight multipart upload after an error (a leaked MPU is reaped by a bucket
412
- * lifecycle rule; never a correctness issue). No-op if nothing was started or it already completed. */
556
+ * lifecycle rule; never a correctness issue). No-op if nothing was started or it already completed. It aborts an
557
+ * upload, never an object: a completion that landed survives it. */
413
558
  async abort() {
414
559
  if (this.uploadId === void 0) return;
415
560
  const id = this.uploadId;
@@ -434,47 +579,189 @@ import {
434
579
  IntegrityError,
435
580
  MAX_ROW_BYTES,
436
581
  ObjectStoreRegistry,
437
- TransientError as TransientError2,
582
+ TransientError as TransientError3,
583
+ ValidationError as ValidationError4,
438
584
  WriteConflictError as WriteConflictError2,
439
585
  normalizeObjectPrefix as normalizeObjectPrefix2
440
586
  } from "@cloudbitmaps/core/driver-kit";
441
587
  import {
588
+ DeleteObjectCommand as DeleteObjectCommand3,
442
589
  GetObjectCommand as GetObjectCommand2,
443
590
  ListObjectsV2Command as ListObjectsV2Command2,
444
- PutObjectCommand as PutObjectCommand2
591
+ PutObjectCommand as PutObjectCommand3
445
592
  } from "@aws-sdk/client-s3";
446
- var S3Store = class {
447
- constructor(client, bucket) {
593
+
594
+ // src/client-probe.ts
595
+ import { DeleteObjectCommand as DeleteObjectCommand2, PutObjectCommand as PutObjectCommand2 } from "@aws-sdk/client-s3";
596
+ var PROBE_KEY = "cloudbitmaps-probe";
597
+ var PLACEHOLDER_CREDENTIALS = { accessKeyId: "cloudbitmaps-probe", secretAccessKey: "unused" };
598
+ var NEVER_SENDS = {
599
+ handle: () => Promise.reject(new Error("the client probe was asked to send a request")),
600
+ updateHttpClientConfig: () => {
601
+ },
602
+ httpHandlerConfigs: () => ({}),
603
+ destroy: () => {
604
+ }
605
+ };
606
+ var SILENT_LOGGER = {
607
+ debug: () => {
608
+ },
609
+ info: () => {
610
+ },
611
+ warn: () => {
612
+ },
613
+ error: () => {
614
+ }
615
+ };
616
+ function cloneForProbe(client) {
617
+ const parts = client;
618
+ if (typeof parts.send !== "function" || typeof parts.config !== "object" || parts.config === null) {
619
+ return void 0;
620
+ }
621
+ if (typeof parts.constructor !== "function" || parts.constructor === Object) return void 0;
622
+ const Class = parts.constructor;
623
+ const clone = new Class({
624
+ ...parts.config,
625
+ credentials: PLACEHOLDER_CREDENTIALS,
626
+ requestHandler: NEVER_SENDS,
627
+ logger: SILENT_LOGGER
628
+ });
629
+ return typeof clone.send === "function" && clone.middlewareStack != null ? clone : void 0;
630
+ }
631
+ async function serialise(client, command) {
632
+ let seen;
633
+ command.middlewareStack.addRelativeTo(
634
+ () => (args) => {
635
+ seen = args.request;
636
+ return Promise.resolve({ response: {}, output: { $metadata: {} } });
637
+ },
638
+ { relation: "after", toMiddleware: "serializerMiddleware", name: "cloudbitmapsProbe" }
639
+ );
640
+ const handler = command.resolveMiddleware(client.middlewareStack, client.config, {});
641
+ await handler(command);
642
+ if (seen === void 0) throw new Error("the probe saw no request");
643
+ return seen;
644
+ }
645
+ var hasHeader = (request, name) => request.headers?.[name] !== void 0;
646
+ async function probeClient(client, bucket) {
647
+ let probe;
648
+ try {
649
+ probe = cloneForProbe(client);
650
+ if (probe === void 0) return void 0;
651
+ const del = await serialise(
652
+ probe,
653
+ new DeleteObjectCommand2({ Bucket: bucket, Key: PROBE_KEY, IfMatch: '"probe"' })
654
+ );
655
+ const put = await serialise(
656
+ probe,
657
+ new PutObjectCommand2({
658
+ Bucket: bucket,
659
+ Key: PROBE_KEY,
660
+ Body: new Uint8Array(0),
661
+ IfMatch: '"probe"',
662
+ IfNoneMatch: "*"
663
+ })
664
+ );
665
+ if (typeof del.hostname !== "string" || del.hostname === "") return void 0;
666
+ return {
667
+ host: del.hostname,
668
+ sendsDeleteIfMatch: hasHeader(del, "if-match"),
669
+ sendsPutIfMatch: hasHeader(put, "if-match"),
670
+ sendsPutIfNoneMatch: hasHeader(put, "if-none-match")
671
+ };
672
+ } catch {
673
+ return void 0;
674
+ } finally {
675
+ probe?.destroy();
676
+ }
677
+ }
678
+ var AWS_DOMAINS = [".amazonaws.com", ".amazonaws.com.cn"];
679
+ function isAwsS3Host(hostname) {
680
+ const host = hostname.toLowerCase().replace(/\.$/, "");
681
+ if (!AWS_DOMAINS.some((domain) => host.endsWith(domain))) return false;
682
+ return host.split(".").some((label) => label === "s3" || label.startsWith("s3-") || label.startsWith("s3express-"));
683
+ }
684
+
685
+ // src/registry.ts
686
+ var S3RegistryStore = class {
687
+ constructor(client, bucket, readTimeoutMs, explicitConditionalDelete) {
448
688
  this.client = client;
449
689
  this.bucket = bucket;
690
+ this.readTimeoutMs = readTimeoutMs;
691
+ this.explicitConditionalDelete = explicitConditionalDelete;
450
692
  }
451
693
  client;
452
694
  bucket;
695
+ readTimeoutMs;
696
+ explicitConditionalDelete;
453
697
  label = "S3";
454
- async read(key) {
455
- let res;
456
- try {
457
- res = await this.client.send(new GetObjectCommand2({ Bucket: this.bucket, Key: key }));
458
- } catch (err) {
459
- if (isNotFound(err)) return null;
460
- throw mapError(err);
461
- }
462
- if ((res.ContentLength ?? 0) > MAX_ROW_BYTES) {
463
- throw new IntegrityError(
464
- `registry object ${res.ContentLength}B exceeds cap ${MAX_ROW_BYTES}B`
698
+ probed;
699
+ facts;
700
+ /**
701
+ * Whether a delete may rely on `If-Match`: the caller's setting, else whether the client's host is AWS S3, and in
702
+ * neither case when the SDK is seen not to send the header. `false` until the client has been asked, unless the caller
703
+ * vouched for the store.
704
+ */
705
+ get conditionalDelete() {
706
+ if (this.explicitConditionalDelete === false) return false;
707
+ const facts = this.facts;
708
+ if (this.explicitConditionalDelete === true) return facts?.sendsDeleteIfMatch !== false;
709
+ return facts !== void 0 && facts.sendsDeleteIfMatch && isAwsS3Host(facts.host);
710
+ }
711
+ /** Ask the client once what it does with the registry's requests; every caller waits for the same answer. */
712
+ settle() {
713
+ this.probed ??= probeClient(this.client, this.bucket).then((facts) => {
714
+ this.facts = facts;
715
+ });
716
+ return this.probed;
717
+ }
718
+ /** Refuse a write the SDK would send without its precondition: it would land as a plain overwrite. */
719
+ refuseUnfencedWrites(expect) {
720
+ const header = expect === "absent" ? "If-None-Match" : "If-Match";
721
+ const sent = expect === "absent" ? this.facts?.sendsPutIfNoneMatch : this.facts?.sendsPutIfMatch;
722
+ if (sent === false) {
723
+ throw new ValidationError4(
724
+ `the S3 client does not send ${header} on a PutObject: its @aws-sdk/client-s3 predates the member, so a registry write would overwrite whatever is there and lose a concurrent writer's change. Use a newer @aws-sdk/client-s3; the registry sent nothing`
465
725
  );
466
726
  }
467
- if (res.Body === void 0) {
468
- throw new IntegrityError(`registry object has an empty body: ${key}`);
469
- }
470
- const bytes = await res.Body.transformToByteArray();
471
- return { bytes, version: res.ETag ?? "" };
727
+ }
728
+ async read(key) {
729
+ await this.settle();
730
+ return timedRead("GetObject", this.readTimeoutMs, async (options) => {
731
+ let res;
732
+ try {
733
+ res = await this.client.send(
734
+ new GetObjectCommand2({ Bucket: this.bucket, Key: key }),
735
+ options
736
+ );
737
+ } catch (err) {
738
+ if (isNotFound(err)) return null;
739
+ throw mapError(err);
740
+ }
741
+ try {
742
+ if ((res.ContentLength ?? 0) > MAX_ROW_BYTES) {
743
+ throw new IntegrityError(
744
+ `registry object ${res.ContentLength}B exceeds cap ${MAX_ROW_BYTES}B`
745
+ );
746
+ }
747
+ if (res.Body === void 0) {
748
+ throw new IntegrityError(`registry object has an empty body: ${key}`);
749
+ }
750
+ const bytes = await res.Body.transformToByteArray();
751
+ return { bytes, version: res.ETag ?? "" };
752
+ } catch (err) {
753
+ destroyBody(res.Body);
754
+ throw err instanceof IntegrityError ? err : mapError(err);
755
+ }
756
+ });
472
757
  }
473
758
  async write(key, body, expect) {
759
+ await this.settle();
760
+ this.refuseUnfencedWrites(expect);
474
761
  try {
475
762
  await sendOnce(
476
763
  this.client,
477
- new PutObjectCommand2({
764
+ new PutObjectCommand3({
478
765
  Bucket: this.bucket,
479
766
  Key: key,
480
767
  Body: body,
@@ -490,7 +777,21 @@ var S3Store = class {
490
777
  throw mapError(err);
491
778
  }
492
779
  }
780
+ async delete(key, expect) {
781
+ try {
782
+ await sendOnce(
783
+ this.client,
784
+ new DeleteObjectCommand3({ Bucket: this.bucket, Key: key, IfMatch: expect.version })
785
+ );
786
+ } catch (err) {
787
+ if (isConditionalConflict(err) || isNotFound(err)) {
788
+ throw new WriteConflictError2(`registry OCC conflict deleting ${key}`);
789
+ }
790
+ throw mapError(err);
791
+ }
792
+ }
493
793
  async *listKeys(prefix) {
794
+ await this.settle();
494
795
  let token;
495
796
  do {
496
797
  let res;
@@ -512,19 +813,34 @@ var S3Store = class {
512
813
  } while (token !== void 0);
513
814
  }
514
815
  };
816
+ function destroyBody(body) {
817
+ body?.destroy?.();
818
+ }
515
819
  function mapError(err) {
516
820
  if (isTransient(err)) {
517
- return new TransientError2(
821
+ return new TransientError3(
518
822
  `transient S3 fault: ${err?.name ?? "unknown"}`,
519
823
  { cause: err }
520
824
  );
521
825
  }
522
826
  return err;
523
827
  }
828
+ function checkConditionalDelete(value) {
829
+ if (value === void 0) return void 0;
830
+ if (typeof value !== "boolean") {
831
+ throw new ValidationError4(`conditionalDelete must be a boolean; got ${String(value)}`);
832
+ }
833
+ return value;
834
+ }
524
835
  var S3RegistryDriver = class extends ObjectStoreRegistry {
525
836
  constructor(options) {
526
837
  super(
527
- new S3Store(options.client, options.bucket),
838
+ new S3RegistryStore(
839
+ options.client,
840
+ options.bucket,
841
+ resolveReadTimeoutMs(options.readTimeoutMs),
842
+ checkConditionalDelete(options.conditionalDelete)
843
+ ),
528
844
  normalizeObjectPrefix2(options.prefix),
529
845
  options.now ?? (() => Date.now())
530
846
  );
@@ -542,19 +858,21 @@ var S3_STORAGE_OPTION_KEYS = [
542
858
  "credentials",
543
859
  "maxObjectBytes",
544
860
  "partBytes",
545
- "now"
861
+ "readTimeoutMs",
862
+ "now",
863
+ "conditionalDelete"
546
864
  ];
547
865
  var CLIENT_SETTINGS = ["region", "endpoint", "pathStyle", "credentials"];
548
866
  function refuseUnknown(name, options, keys, hint) {
549
867
  if (options === null || typeof options !== "object") {
550
- throw new ValidationError3(
868
+ throw new ValidationError5(
551
869
  `${name} needs an options object \u2014 got ${options === null ? "null" : typeof options}`
552
870
  );
553
871
  }
554
872
  const unknown = Object.keys(options).filter((k) => !keys.includes(k));
555
873
  if (unknown.length > 0) {
556
874
  const list = (ks) => ks.map((k) => `\`${k}\``).join(", ");
557
- throw new ValidationError3(
875
+ throw new ValidationError5(
558
876
  `${name} does not take ${list(unknown)}. It takes ${list(keys)}; ${hint}.`
559
877
  );
560
878
  }
@@ -569,7 +887,7 @@ var S3Storage = class {
569
887
  if (options.client !== void 0 && options.client !== null) {
570
888
  const ignored = CLIENT_SETTINGS.filter((k) => options[k] !== void 0);
571
889
  if (ignored.length > 0) {
572
- throw new ValidationError3(
890
+ throw new ValidationError5(
573
891
  `S3Storage takes \`client\` OR ${CLIENT_SETTINGS.map((k) => `\`${k}\``).join(" / ")}, not both \u2014 got \`client\` with ${ignored.map((k) => `\`${k}\``).join(", ")}; the \`client\` already carries them, so configure them on the client, or drop \`client\``
574
892
  );
575
893
  }
@@ -585,7 +903,8 @@ var S3Storage = class {
585
903
  const shared = {
586
904
  client: this.client,
587
905
  bucket: options.bucket,
588
- ...options.prefix === void 0 ? {} : { prefix: options.prefix }
906
+ ...options.prefix === void 0 ? {} : { prefix: options.prefix },
907
+ ...options.readTimeoutMs === void 0 ? {} : { readTimeoutMs: options.readTimeoutMs }
589
908
  };
590
909
  this.storage = new S3StorageDriver({
591
910
  ...shared,
@@ -594,7 +913,8 @@ var S3Storage = class {
594
913
  });
595
914
  this.registry = new S3RegistryDriver({
596
915
  ...shared,
597
- ...options.now === void 0 ? {} : { now: options.now }
916
+ ...options.now === void 0 ? {} : { now: options.now },
917
+ ...options.conditionalDelete === void 0 ? {} : { conditionalDelete: options.conditionalDelete }
598
918
  });
599
919
  brandAsBackend(this);
600
920
  }