@nextlyhq/storage-s3 0.0.2-alpha.62 → 0.0.2-alpha.65

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