@cloudbitmaps/s3 0.11.1 → 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/README.md +29 -9
- package/dist/backend.d.ts +29 -3
- package/dist/client-probe.d.ts +26 -0
- package/dist/index.js +413 -93
- package/dist/index.js.map +4 -4
- package/dist/read-timeout.d.ts +13 -0
- package/dist/registry.d.ts +79 -3
- package/dist/s3-errors.d.ts +8 -0
- package/dist/storage.d.ts +28 -0
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
// src/backend.ts
|
|
2
|
-
import { ValidationError as
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
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
|
|
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
|
|
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({
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
480
|
+
new CompleteMultipartUploadCommand({
|
|
387
481
|
Bucket: this.bucket,
|
|
388
482
|
Key: this.objectKey,
|
|
389
|
-
|
|
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
|
|
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
|
|
591
|
+
PutObjectCommand as PutObjectCommand3
|
|
445
592
|
} from "@aws-sdk/client-s3";
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
if (
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|