@nextlyhq/storage-s3 0.0.2-alpha.60 → 0.0.2-alpha.64

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.cjs CHANGED
@@ -5,6 +5,483 @@ var libStorage = require('@aws-sdk/lib-storage');
5
5
  var s3RequestPresigner = require('@aws-sdk/s3-request-presigner');
6
6
 
7
7
  // src/adapter.ts
8
+
9
+ // ../nextly/dist/chunk-HJ5IQPOY.mjs
10
+ function isDbError(err) {
11
+ if (!err || typeof err !== "object") return false;
12
+ const obj = err;
13
+ return obj.name === "DbError" && typeof obj.kind === "string";
14
+ }
15
+ var NEXTLY_ERROR_STATUS = {
16
+ VALIDATION_ERROR: 400,
17
+ INVALID_INPUT: 400,
18
+ AUTH_REQUIRED: 401,
19
+ AUTH_INVALID_CREDENTIALS: 401,
20
+ TOKEN_EXPIRED: 401,
21
+ FORBIDDEN: 403,
22
+ // The schema builder is off in this environment (production by default).
23
+ // Separate from FORBIDDEN: the caller's permissions are not the problem.
24
+ BUILDER_DISABLED: 403,
25
+ NOT_FOUND: 404,
26
+ CONFLICT: 409,
27
+ DUPLICATE: 409,
28
+ RATE_LIMITED: 429,
29
+ PAYLOAD_TOO_LARGE: 413,
30
+ UNSUPPORTED_MEDIA_TYPE: 415,
31
+ // 422: understood, well-formed, and refused on a rule the caller can act on.
32
+ // Deliberately NOT added to CANONICAL_CODE_FOR_STATUS -- that list drives the
33
+ // status -> code direction, where 422 stays mapped to INVALID_INPUT.
34
+ BUSINESS_RULE_VIOLATION: 422,
35
+ INTERNAL_ERROR: 500,
36
+ DATABASE_ERROR: 500,
37
+ EXTERNAL_SERVICE_ERROR: 502,
38
+ SERVICE_UNAVAILABLE: 503,
39
+ // Outbound-fetch safety (utils/validate-external-url): a URL refused for SSRF
40
+ // safety, and a fetch that timed out / exceeded the size cap / failed to decode.
41
+ EXTERNAL_URL_BLOCKED: 400,
42
+ EXTERNAL_REQUEST_FAILED: 502,
43
+ FILENAME_INVALID: 400,
44
+ EXTENSION_BLOCKED: 400,
45
+ MIME_BLOCKED: 415,
46
+ MIME_NOT_ALLOWED: 415,
47
+ SIZE_EXCEEDED: 413,
48
+ // A stored object exceeded the cap a READ was given, which is a different
49
+ // question from SIZE_EXCEEDED above: that one refuses an upload the caller
50
+ // is sending, this one refuses to buffer an object already stored. Kept
51
+ // apart so a caller discriminating on the code cannot match both.
52
+ STORAGE_READ_TOO_LARGE: 413,
53
+ // A stored object did not answer within the deadline the read was given. 504
54
+ // rather than 500 because the failure is the BACKEND not answering, and a
55
+ // caller can act on that difference: a gateway timeout is worth retrying, an
56
+ // internal error is not.
57
+ STORAGE_READ_TIMEOUT: 504,
58
+ // The store answered, and answered badly. 502 rather than 500 because the
59
+ // fault is UPSTREAM of this process: a caller can retry it, and an operator
60
+ // reading the log needs to look at the bucket rather than at this service.
61
+ // Distinct from the timeout above, which never got an answer at all.
62
+ STORAGE_READ_UNREACHABLE: 502,
63
+ MAGIC_BYTE_MISMATCH: 400,
64
+ SVG_SANITIZATION_FAILED: 400,
65
+ UNSUPPORTED_FOR_BACKEND: 415,
66
+ // Plan B — schema bookkeeping consolidation.
67
+ NEXTLY_LEGACY_BOOKKEEPING_DETECTED: 409,
68
+ NEXTLY_UPGRADE_TABLE_NAME_COLLISION: 409,
69
+ NEXTLY_UPGRADE_IN_PROGRESS: 409,
70
+ // Plan C2 — nextly migrate phases.
71
+ NEXTLY_MIGRATE_LOCK_BUSY: 409,
72
+ NEXTLY_BASELINE_LOCK_NOT_HELD: 409,
73
+ NEXTLY_RESOLVE_LOCK_NOT_HELD: 409,
74
+ // Boot refused to serve: the migrate lock stayed held past the wait deadline,
75
+ // so this process never established whether the schema matches the code. 503
76
+ // rather than 409 — a load balancer should take the instance out of rotation
77
+ // and retry it, which is exactly the recovery this refusal wants.
78
+ NEXTLY_BOOT_MIGRATIONS_NOT_RUN: 503,
79
+ // Still running rather than refused. 503 for the same reason: retry shortly.
80
+ NEXTLY_BOOT_MIGRATIONS_PENDING: 503,
81
+ NEXTLY_CORE_DESTRUCTIVE_REFUSED: 409,
82
+ NEXTLY_MIGRATION_DRIFT: 409,
83
+ NEXTLY_MIGRATION_APPLY_FAILED: 500,
84
+ // Plan C3 — migrate:resolve recovery command.
85
+ NEXTLY_MIGRATION_FILE_MISSING: 404,
86
+ NEXTLY_MIGRATION_SNAPSHOT_MISSING: 404,
87
+ NEXTLY_MIGRATION_RESOLVE_DRIFT: 409,
88
+ NEXTLY_MIGRATION_RESOLVE_PRECONDITION: 409,
89
+ // Plan D — UI schema support.
90
+ NEXTLY_UI_SCHEMA_INVALID: 400,
91
+ NEXTLY_SCHEMA_SLUG_COLLISION: 409,
92
+ NEXTLY_SCHEMA_RELATION_TARGET_MISSING: 400,
93
+ // Plugin platform (P2b) — schema extend (contributes.extend) + relations (D15).
94
+ NEXTLY_SCHEMA_EXTEND_TARGET_UNKNOWN: 400,
95
+ NEXTLY_SCHEMA_EXTEND_FIELD_DUPLICATE: 409,
96
+ NEXTLY_SCHEMA_CROSS_PLUGIN_RELATION: 409,
97
+ // Plugin platform (P2c) — framework remap (.rename()).
98
+ NEXTLY_SCHEMA_RENAME_UNKNOWN_TARGET: 400,
99
+ // Plugin platform — a declared admin.clientConfig that cannot be delivered
100
+ // to the browser, refused at boot rather than serialized mangled.
101
+ NEXTLY_PLUGIN_CLIENT_CONFIG_INVALID: 500,
102
+ // Plugin platform — a contributed admin widget that cannot be delivered to
103
+ // the browser. Refused at boot because it is serialized into the ONE
104
+ // `/api/admin-meta/workspace` payload: a value `JSON.stringify` throws on
105
+ // fails that request for every admin, not just the widget's own card.
106
+ NEXTLY_PLUGIN_ADMIN_WIDGET_INVALID: 500,
107
+ // Plugin platform (P0) — boot-time plugin dependency/version resolution.
108
+ PLUGIN_RESOLUTION_ERROR: 500,
109
+ // Plugin platform (P4) — contributes.routes collection (D25).
110
+ NEXTLY_ROUTE_COLLISION: 409,
111
+ NEXTLY_ROUTE_INVALID_PATH: 400,
112
+ // An email transport whose library is an optional peer dependency the host
113
+ // has not installed. 503 rather than 500: the request is not malformed and
114
+ // nothing is broken, the install simply cannot carry it out yet, and the
115
+ // remedy is one command on the server rather than a change by the caller.
116
+ NEXTLY_EMAIL_TRANSPORT_UNAVAILABLE: 503,
117
+ // The tooling that compiles `nextly.config.ts` is an optional peer the host
118
+ // has not installed. 503 rather than 500 for the same reason as the mail
119
+ // transport above: nothing is broken and the request is not malformed, the
120
+ // install simply cannot carry it out until one command is run.
121
+ NEXTLY_CONFIG_TOOLING_UNAVAILABLE: 503
122
+ };
123
+ var CANONICAL_CODE_FOR_STATUS = [
124
+ "VALIDATION_ERROR",
125
+ "AUTH_REQUIRED",
126
+ "FORBIDDEN",
127
+ "NOT_FOUND",
128
+ "CONFLICT",
129
+ "PAYLOAD_TOO_LARGE",
130
+ "UNSUPPORTED_MEDIA_TYPE",
131
+ "RATE_LIMITED",
132
+ "EXTERNAL_SERVICE_ERROR",
133
+ "SERVICE_UNAVAILABLE"
134
+ ];
135
+ ({
136
+ ...Object.fromEntries(
137
+ CANONICAL_CODE_FOR_STATUS.map((code) => [NEXTLY_ERROR_STATUS[code], code])
138
+ )});
139
+ var DB_ERROR_MAPPING = {
140
+ "unique-violation": {
141
+ code: "DUPLICATE",
142
+ statusCode: 409,
143
+ publicMessage: "Resource already exists."
144
+ },
145
+ "fk-violation": {
146
+ code: "VALIDATION_ERROR",
147
+ statusCode: 400,
148
+ publicMessage: "Referenced record does not exist."
149
+ },
150
+ "not-null-violation": {
151
+ code: "VALIDATION_ERROR",
152
+ statusCode: 400,
153
+ publicMessage: "A required field is missing."
154
+ },
155
+ constraint: {
156
+ code: "VALIDATION_ERROR",
157
+ statusCode: 400,
158
+ publicMessage: "The provided data violates a constraint."
159
+ },
160
+ deadlock: {
161
+ code: "CONFLICT",
162
+ statusCode: 409,
163
+ publicMessage: "The operation could not be completed. Please retry."
164
+ },
165
+ "serialization-failure": {
166
+ code: "CONFLICT",
167
+ statusCode: 409,
168
+ publicMessage: "The operation could not be completed. Please retry."
169
+ },
170
+ timeout: {
171
+ code: "DATABASE_ERROR",
172
+ statusCode: 500,
173
+ publicMessage: "The operation timed out. Please try again."
174
+ },
175
+ "connection-lost": {
176
+ code: "DATABASE_ERROR",
177
+ statusCode: 500,
178
+ publicMessage: "A temporary database error occurred. Please try again."
179
+ },
180
+ syntax: {
181
+ code: "INTERNAL_ERROR",
182
+ statusCode: 500,
183
+ publicMessage: "An unexpected error occurred."
184
+ },
185
+ internal: {
186
+ code: "INTERNAL_ERROR",
187
+ statusCode: 500,
188
+ publicMessage: "An unexpected error occurred."
189
+ }
190
+ };
191
+ var NEXTLY_ERROR_BRAND = Symbol.for("nextly/NextlyError");
192
+ function hasBrand(value) {
193
+ if (value === null || typeof value !== "object" && typeof value !== "function") {
194
+ return false;
195
+ }
196
+ return value[NEXTLY_ERROR_BRAND] === true;
197
+ }
198
+ var NextlyError = class _NextlyError extends Error {
199
+ code;
200
+ statusCode;
201
+ publicMessage;
202
+ publicData;
203
+ messageKey;
204
+ logMessage;
205
+ logContext;
206
+ cause;
207
+ timestamp;
208
+ constructor(opts) {
209
+ super(opts.publicMessage);
210
+ this.name = "NextlyError";
211
+ this.code = opts.code;
212
+ this.publicMessage = opts.publicMessage;
213
+ this.publicData = opts.publicData;
214
+ this.messageKey = opts.messageKey;
215
+ this.logMessage = opts.logMessage;
216
+ this.logContext = opts.logContext;
217
+ this.cause = opts.cause;
218
+ this.timestamp = /* @__PURE__ */ new Date();
219
+ this.statusCode = _NextlyError.resolveStatusCode(opts);
220
+ Error.captureStackTrace?.(this, _NextlyError);
221
+ }
222
+ static {
223
+ _NextlyError.prototype[NEXTLY_ERROR_BRAND] = true;
224
+ }
225
+ static resolveStatusCode(opts) {
226
+ if (typeof opts.statusCode === "number") return opts.statusCode;
227
+ if (opts.code in NEXTLY_ERROR_STATUS) {
228
+ return NEXTLY_ERROR_STATUS[opts.code];
229
+ }
230
+ return 500;
231
+ }
232
+ /** HTTP-safe JSON. Strips logMessage / logContext / cause / stack. */
233
+ toResponseJSON(requestId) {
234
+ const json = {
235
+ code: String(this.code),
236
+ message: this.publicMessage,
237
+ requestId
238
+ };
239
+ if (this.messageKey) json.messageKey = this.messageKey;
240
+ if (this.publicData !== void 0) json.data = this.publicData;
241
+ return json;
242
+ }
243
+ /** Operator-facing JSON for log lines. Includes everything. */
244
+ toLogJSON(requestId) {
245
+ return {
246
+ code: this.code,
247
+ statusCode: this.statusCode,
248
+ publicMessage: this.publicMessage,
249
+ messageKey: this.messageKey,
250
+ logMessage: this.logMessage,
251
+ logContext: this.logContext,
252
+ cause: this.cause ? {
253
+ name: this.cause.name,
254
+ message: this.cause.message,
255
+ stack: this.cause.stack
256
+ } : void 0,
257
+ timestamp: this.timestamp.toISOString(),
258
+ requestId
259
+ };
260
+ }
261
+ // ────────────────────────────────────────────────────────────────────
262
+ // Static factories — the recommended throw site for every common case.
263
+ // Public messages here follow the §13.8 rubric: complete sentence,
264
+ // generic, no identifiers, no value echoing, no policy hints.
265
+ // ────────────────────────────────────────────────────────────────────
266
+ static invalidCredentials(opts) {
267
+ return new _NextlyError({
268
+ code: "AUTH_INVALID_CREDENTIALS",
269
+ publicMessage: "Invalid email or password.",
270
+ logMessage: "Login failed",
271
+ logContext: opts?.logContext
272
+ });
273
+ }
274
+ static authRequired(opts) {
275
+ return new _NextlyError({
276
+ code: "AUTH_REQUIRED",
277
+ publicMessage: "Authentication required.",
278
+ logContext: opts?.logContext
279
+ });
280
+ }
281
+ /**
282
+ * Distinct from `authRequired`: the caller was authenticated but the
283
+ * session token expired. Clients key on the TOKEN_EXPIRED *code* to
284
+ * silently refresh and retry rather than redirecting to login; the public
285
+ * message stays the generic spec §13.6 string ("Authentication required.")
286
+ * so the wire never reveals the session state — same as `authRequired`.
287
+ */
288
+ static tokenExpired(opts) {
289
+ return new _NextlyError({
290
+ code: "TOKEN_EXPIRED",
291
+ publicMessage: "Authentication required.",
292
+ logContext: opts?.logContext
293
+ });
294
+ }
295
+ static notFound(opts) {
296
+ return new _NextlyError({
297
+ code: "NOT_FOUND",
298
+ publicMessage: opts?.message ?? "Not found.",
299
+ cause: opts?.cause,
300
+ logContext: opts?.logContext
301
+ });
302
+ }
303
+ static forbidden(opts) {
304
+ return new _NextlyError({
305
+ code: "FORBIDDEN",
306
+ publicMessage: "You don't have permission to perform this action.",
307
+ cause: opts?.cause,
308
+ logContext: opts?.logContext
309
+ });
310
+ }
311
+ /**
312
+ * A call the caller got wrong, where naming the mistake IS the value of the
313
+ * error.
314
+ *
315
+ * The only factory that takes its public message from the caller. The
316
+ * generic messages elsewhere exist so an HTTP response cannot leak internal
317
+ * detail; this one is for arguments and configuration a developer controls
318
+ * and must be told about — a missing option, an unusable combination — where
319
+ * `internal()` would reduce the one useful sentence to "An unexpected error
320
+ * occurred." Do not pass user-supplied data through it.
321
+ */
322
+ static invalidInput(opts) {
323
+ return new _NextlyError({
324
+ code: "INVALID_INPUT",
325
+ publicMessage: opts.message,
326
+ logContext: opts.logContext
327
+ });
328
+ }
329
+ static validation(opts) {
330
+ return new _NextlyError({
331
+ code: "VALIDATION_ERROR",
332
+ publicMessage: "Validation failed.",
333
+ publicData: { errors: opts.errors },
334
+ cause: opts.cause,
335
+ logContext: opts.logContext
336
+ });
337
+ }
338
+ static conflict(opts) {
339
+ return new _NextlyError({
340
+ code: "CONFLICT",
341
+ publicMessage: opts?.message ?? "The resource has changed since you last loaded it. Please refresh and try again.",
342
+ cause: opts?.cause,
343
+ logContext: { reason: opts?.reason, ...opts?.logContext }
344
+ });
345
+ }
346
+ static duplicate(opts) {
347
+ return new _NextlyError({
348
+ code: "DUPLICATE",
349
+ publicMessage: "Resource already exists.",
350
+ logContext: opts?.logContext
351
+ });
352
+ }
353
+ static rateLimited(opts) {
354
+ return new _NextlyError({
355
+ code: "RATE_LIMITED",
356
+ publicMessage: "Too many requests. Please try again later.",
357
+ publicData: opts?.retryAfterSeconds !== void 0 ? { retryAfterSeconds: opts.retryAfterSeconds } : void 0,
358
+ logContext: opts?.logContext
359
+ });
360
+ }
361
+ static internal(opts) {
362
+ return new _NextlyError({
363
+ code: "INTERNAL_ERROR",
364
+ publicMessage: "An unexpected error occurred.",
365
+ cause: opts?.cause,
366
+ logContext: opts?.logContext
367
+ });
368
+ }
369
+ // Accepts an optional `logMessage` override so callers (e.g. the health
370
+ // route) can record a specific operator narrative ("Health check failed")
371
+ // while the public message stays canonical per spec §13.8.5.
372
+ static serviceUnavailable(opts) {
373
+ return new _NextlyError({
374
+ code: "SERVICE_UNAVAILABLE",
375
+ publicMessage: opts?.publicMessage ?? "Service unavailable. Please try again later.",
376
+ logMessage: opts?.logMessage,
377
+ cause: opts?.cause,
378
+ logContext: opts?.logContext
379
+ });
380
+ }
381
+ /**
382
+ * Convert a DbError (or arbitrary unknown thrown by the DB layer) to a
383
+ * NextlyError with a generic public message and rich logContext. Used by
384
+ * `withDbErrors` for auto-conversion (Pattern A) and by services that
385
+ * catch DB errors at boundaries (Pattern B). Spec §8.2 mapping table.
386
+ *
387
+ * Never leaks DB driver text, constraint names, or table names into
388
+ * `publicMessage`. All DB context goes into `logContext`. The original
389
+ * DbError is preserved as `cause`.
390
+ */
391
+ static fromDatabaseError(error) {
392
+ if (isDbError(error)) {
393
+ const mapping = DB_ERROR_MAPPING[error.kind];
394
+ const logContext = {
395
+ dbKind: error.kind,
396
+ dialect: error.dialect
397
+ };
398
+ if (error.code !== void 0) logContext.dbCode = error.code;
399
+ if (error.meta !== void 0) logContext.meta = error.meta;
400
+ return new _NextlyError({
401
+ code: mapping.code,
402
+ statusCode: mapping.statusCode,
403
+ publicMessage: mapping.publicMessage,
404
+ logMessage: "Database error",
405
+ logContext,
406
+ cause: error
407
+ });
408
+ }
409
+ return new _NextlyError({
410
+ code: "INTERNAL_ERROR",
411
+ statusCode: 500,
412
+ publicMessage: "An unexpected error occurred.",
413
+ logMessage: "Non-DbError passed to fromDatabaseError",
414
+ cause: error instanceof Error ? error : void 0,
415
+ logContext: error instanceof Error ? void 0 : { value: String(error) }
416
+ });
417
+ }
418
+ // ────────────────────────────────────────────────────────────────────
419
+ // Type guards. Structural rather than `instanceof` so they survive
420
+ // package-boundary mismatches (one consumer's NextlyError is a
421
+ // different module instance from another's).
422
+ // ────────────────────────────────────────────────────────────────────
423
+ static is(err) {
424
+ return hasBrand(err);
425
+ }
426
+ static isCode(err, code) {
427
+ return hasBrand(err) && err.code === code;
428
+ }
429
+ static isNotFound(err) {
430
+ return _NextlyError.isCode(err, "NOT_FOUND");
431
+ }
432
+ static isValidation(err) {
433
+ return _NextlyError.isCode(err, "VALIDATION_ERROR");
434
+ }
435
+ static isAuthRequired(err) {
436
+ return _NextlyError.isCode(err, "AUTH_REQUIRED");
437
+ }
438
+ static isForbidden(err) {
439
+ return _NextlyError.isCode(err, "FORBIDDEN");
440
+ }
441
+ static isConflict(err) {
442
+ return _NextlyError.isCode(err, "CONFLICT");
443
+ }
444
+ static isRateLimited(err) {
445
+ return _NextlyError.isCode(err, "RATE_LIMITED");
446
+ }
447
+ };
448
+
449
+ // ../nextly/dist/chunk-W77AQUUQ.mjs
450
+ var StorageReadTooLargeError = class extends NextlyError {
451
+ constructor(path, maxBytes, size) {
452
+ super({
453
+ code: "STORAGE_READ_TOO_LARGE",
454
+ publicMessage: "The stored file is larger than the limit for this read.",
455
+ logContext: {
456
+ path,
457
+ maxBytes,
458
+ ...size === void 0 ? {} : { size }
459
+ }
460
+ });
461
+ this.path = path;
462
+ this.maxBytes = maxBytes;
463
+ this.size = size;
464
+ }
465
+ };
466
+
467
+ // ../nextly/dist/chunk-C2VHLQKK.mjs
468
+ var DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
469
+ var DEFAULT_TIMEOUT_MS = 3e4;
470
+ var DEFAULT_READ_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
471
+ var DEFAULT_READ_MAX_BYTES = DEFAULT_MAX_RESPONSE_BYTES;
472
+ function resolveReadBounds(options) {
473
+ return {
474
+ maxBytes: options?.maxBytes ?? DEFAULT_READ_MAX_BYTES,
475
+ timeoutMs: options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS
476
+ };
477
+ }
478
+
479
+ // src/adapter.ts
480
+ function abortAfter(options) {
481
+ return {
482
+ abortSignal: AbortSignal.timeout(resolveReadBounds(options).timeoutMs)
483
+ };
484
+ }
8
485
  var S3StorageAdapter = class {
9
486
  /**
10
487
  * Create a new S3 storage adapter.
@@ -268,6 +745,49 @@ var S3StorageAdapter = class {
268
745
  throw error;
269
746
  }
270
747
  }
748
+ /**
749
+ * Read a stored object back as bytes, or `null` when it is not there.
750
+ *
751
+ * The contract declares this OPTIONAL and, until now, no adapter supplied
752
+ * it — so a caller reaching for `adapter.read` got `undefined` and fell
753
+ * through whatever branch followed. An optional member nothing implements is
754
+ * a contract that reads as a capability and behaves as an absence.
755
+ *
756
+ * BUFFERS rather than streams, because that is the declared return type and
757
+ * widening it here would make this adapter answer a different question from
758
+ * its siblings. That bounds what it should be used for: an asset the server
759
+ * must serve from its own origin — a font file, a small document — rather
760
+ * than arbitrarily large media, which belongs behind {@link getSignedUrl} or
761
+ * a public URL. A streaming variant is a separate contract member, not a
762
+ * quiet change to this one.
763
+ *
764
+ * `null` for a missing key rather than a throw, matching
765
+ * {@link getMetadata}: absence is an ordinary answer about the bucket, while
766
+ * a credential or network failure is not, so only the first is folded into
767
+ * the return value.
768
+ *
769
+ * @param filePath - Storage path/key
770
+ * @returns The object's bytes, or `null` when no such key exists
771
+ */
772
+ async read(filePath, options) {
773
+ try {
774
+ const command = new clientS3.GetObjectCommand({
775
+ Bucket: this.resolvedConfig.bucket,
776
+ Key: filePath
777
+ });
778
+ const response = await this.client.send(command, abortAfter(options));
779
+ if (response.Body === void 0) return Buffer.alloc(0);
780
+ const cap = resolveReadBounds(options).maxBytes;
781
+ this.refuseOverCap(filePath, response.ContentLength, cap);
782
+ return await this.readCapped(filePath, response.Body, cap);
783
+ } catch (error) {
784
+ if (error instanceof Error && error.name === "NoSuchBucket") throw error;
785
+ if (this.isNotFoundError(error)) {
786
+ return null;
787
+ }
788
+ throw error;
789
+ }
790
+ }
271
791
  /**
272
792
  * Generate signed URL for temporary private file access.
273
793
  *
@@ -360,6 +880,61 @@ var S3StorageAdapter = class {
360
880
  * @param error - Error to check
361
881
  * @returns true if error indicates file not found
362
882
  */
883
+ /**
884
+ * The object's bytes, refusing once more than `cap` have actually arrived.
885
+ *
886
+ * `ContentLength` is OPTIONAL in the SDK's response type, and this adapter
887
+ * explicitly supports MinIO, R2 and other compatible services — any of which
888
+ * may omit it or report it wrongly. A preflight check against metadata is
889
+ * therefore a courtesy that fails OPEN: trusting it alone leaves the cap
890
+ * unenforced exactly where an unusual backend is involved, which is where a
891
+ * caller most needs it.
892
+ *
893
+ * Counted while consuming, so an oversized body is abandoned partway rather
894
+ * than after it has all been buffered — the cap exists to bound memory, and
895
+ * one applied after `transformToByteArray` has already spent it.
896
+ *
897
+ * Falls back to buffering when the body is not async-iterable, which the SDK
898
+ * types permit. Stated rather than hidden: on that path the cap bounds what a
899
+ * caller RECEIVES rather than what the process allocates.
900
+ */
901
+ async readCapped(filePath, body, cap) {
902
+ const iterable = body;
903
+ if (typeof iterable[Symbol.asyncIterator] !== "function") {
904
+ const whole = Buffer.from(await body.transformToByteArray());
905
+ if (whole.byteLength > cap) {
906
+ throw new StorageReadTooLargeError(filePath, cap, whole.byteLength);
907
+ }
908
+ return whole;
909
+ }
910
+ const chunks = [];
911
+ let received = 0;
912
+ for await (const chunk of body) {
913
+ received += chunk.byteLength;
914
+ if (received > cap) {
915
+ throw new StorageReadTooLargeError(filePath, cap, received);
916
+ }
917
+ chunks.push(Buffer.from(chunk));
918
+ }
919
+ return Buffer.concat(chunks);
920
+ }
921
+ /**
922
+ * Refuse a read whose object is already known to exceed the caller's cap.
923
+ *
924
+ * Its own method rather than three conditions inside `read`, because the
925
+ * decision has nothing to do with the surrounding error handling and reads
926
+ * as noise there — and because `read`'s branching is the part a reviewer has
927
+ * to hold in their head to check the absent-versus-unavailable distinction.
928
+ *
929
+ * Both `undefined` cases mean "no opinion" and pass: a caller that named no
930
+ * cap has none, and a store that reported no length gives nothing to compare.
931
+ * The second is deliberately permissive — refusing an unmeasured object would
932
+ * reject valid reads whenever S3 omits the header.
933
+ */
934
+ refuseOverCap(filePath, declared, cap) {
935
+ if (cap === void 0 || declared === void 0 || declared <= cap) return;
936
+ throw new StorageReadTooLargeError(filePath, cap, declared);
937
+ }
363
938
  isNotFoundError(error) {
364
939
  if (error && typeof error === "object") {
365
940
  const e = error;