@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.cjs +575 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +79 -7
- package/dist/index.d.ts +79 -7
- package/dist/index.mjs +575 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
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;
|