@lenne.tech/nest-server 11.33.1 → 11.34.1

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.
Files changed (49) hide show
  1. package/.claude/rules/testing.md +181 -3
  2. package/FRAMEWORK-API.md +1 -1
  3. package/dist/core/common/helpers/gridfs.helper.d.ts +1 -0
  4. package/dist/core/common/helpers/gridfs.helper.js +43 -5
  5. package/dist/core/common/helpers/gridfs.helper.js.map +1 -1
  6. package/dist/core/common/helpers/validation-message.helper.d.ts +3 -0
  7. package/dist/core/common/helpers/validation-message.helper.js +41 -0
  8. package/dist/core/common/helpers/validation-message.helper.js.map +1 -0
  9. package/dist/core/common/pipes/map-and-validate.pipe.js +16 -6
  10. package/dist/core/common/pipes/map-and-validate.pipe.js.map +1 -1
  11. package/dist/core/common/services/core-s3.service.d.ts +4 -0
  12. package/dist/core/common/services/core-s3.service.js +28 -1
  13. package/dist/core/common/services/core-s3.service.js.map +1 -1
  14. package/dist/core/modules/file/core-file.service.d.ts +4 -2
  15. package/dist/core/modules/file/core-file.service.js +46 -47
  16. package/dist/core/modules/file/core-file.service.js.map +1 -1
  17. package/dist/core/modules/hub/helpers/hub-client-js.helper.js +12 -1
  18. package/dist/core/modules/hub/helpers/hub-client-js.helper.js.map +1 -1
  19. package/dist/core/modules/hub/hub-action-messages.d.ts +1 -0
  20. package/dist/core/modules/hub/hub-action-messages.js +1 -0
  21. package/dist/core/modules/hub/hub-action-messages.js.map +1 -1
  22. package/dist/core/modules/hub/interfaces/hub-panels.interface.d.ts +9 -0
  23. package/dist/core/modules/hub/services/core-hub-actions.service.d.ts +2 -0
  24. package/dist/core/modules/hub/services/core-hub-actions.service.js.map +1 -1
  25. package/dist/core/modules/hub/services/core-hub-db.service.d.ts +10 -2
  26. package/dist/core/modules/hub/services/core-hub-db.service.js +85 -29
  27. package/dist/core/modules/hub/services/core-hub-db.service.js.map +1 -1
  28. package/dist/server/modules/file/file.service.d.ts +2 -1
  29. package/dist/server/modules/file/file.service.js +2 -2
  30. package/dist/server/modules/file/file.service.js.map +1 -1
  31. package/dist/tsconfig.build.tsbuildinfo +1 -1
  32. package/migration-guides/11.23.x-to-11.24.0.md +1 -1
  33. package/migration-guides/11.32.x-to-11.33.x.md +2 -0
  34. package/migration-guides/11.33.x-to-11.34.x.md +394 -0
  35. package/migration-guides/11.34.0-to-11.34.1.md +132 -0
  36. package/package.json +3 -1
  37. package/src/core/common/helpers/gridfs.helper.ts +92 -7
  38. package/src/core/common/helpers/validation-message.helper.ts +83 -0
  39. package/src/core/common/pipes/map-and-validate.pipe.ts +25 -7
  40. package/src/core/common/services/core-s3.service.ts +71 -9
  41. package/src/core/modules/file/README.md +42 -6
  42. package/src/core/modules/file/core-file.service.ts +131 -59
  43. package/src/core/modules/hub/README.md +35 -9
  44. package/src/core/modules/hub/helpers/hub-client-js.helper.ts +12 -1
  45. package/src/core/modules/hub/hub-action-messages.ts +9 -1
  46. package/src/core/modules/hub/interfaces/hub-panels.interface.ts +31 -1
  47. package/src/core/modules/hub/services/core-hub-actions.service.ts +10 -2
  48. package/src/core/modules/hub/services/core-hub-db.service.ts +155 -31
  49. package/src/server/modules/file/file.service.ts +20 -3
@@ -72,7 +72,22 @@ export class GridFSHelper {
72
72
  }
73
73
 
74
74
  /**
75
- * Write a file to GridFS from a stream
75
+ * Write a file to GridFS from a stream.
76
+ *
77
+ * **The SOURCE stream needs its own error handler.** `pipe()` does not forward
78
+ * errors, so an error on `stream` used to have no listener at all: Node turns an
79
+ * unhandled `'error'` event into an uncaught exception, which takes the whole
80
+ * process down. That is reachable from ordinary traffic — a client that aborts a
81
+ * GraphQL upload mid-body errors the capacitor stream — and it was
82
+ * driver-conditional: the same abort is a rejected promise under the S3 driver
83
+ * (`streamToBuffer` throws) and under the filesystem driver (`pipeline()`
84
+ * forwards both ends), and a process crash under GridFS, the pre-11.33 default.
85
+ * The migration helper's `uploadFileToGridFS()` already carried this handler; the
86
+ * one on the request path did not.
87
+ *
88
+ * The partial upload is aborted rather than left behind: without it the failed
89
+ * write keeps its chunks in `fs.chunks` with no `fs.files` document naming them,
90
+ * which nothing can ever find or clean up.
76
91
  */
77
92
  static writeFileFromStream(
78
93
  bucket: GridFSBucket,
@@ -90,8 +105,37 @@ export class GridFSHelper {
90
105
  metadata,
91
106
  });
92
107
 
93
- uploadStream.on('error', (error) => {
108
+ // One settle guard for all three paths: aborting a write stream can itself
109
+ // emit, and a second rejection after a resolve would otherwise be silent.
110
+ let settled = false;
111
+ const fail = (error: Error) => {
112
+ if (settled) {
113
+ return;
114
+ }
115
+ settled = true;
94
116
  reject(error);
117
+ };
118
+ const succeed = (fileInfo: GridFSFileInfo) => {
119
+ if (settled) {
120
+ return;
121
+ }
122
+ settled = true;
123
+ resolve(fileInfo);
124
+ };
125
+
126
+ stream.on('error', (error) => {
127
+ // Discard the chunks already written — see the note above.
128
+ Promise.resolve(uploadStream.abort?.()).catch(() => undefined);
129
+ fail(error);
130
+ });
131
+
132
+ uploadStream.on('error', (error) => {
133
+ // `pipe()` only unpipes on a destination error; the source would stay open,
134
+ // holding a GridFS read cursor or an upload capacitor for nothing.
135
+ if (!stream.destroyed) {
136
+ stream.destroy();
137
+ }
138
+ fail(error);
95
139
  });
96
140
 
97
141
  uploadStream.on('finish', () => {
@@ -101,12 +145,12 @@ export class GridFSHelper {
101
145
  .toArray()
102
146
  .then((files) => {
103
147
  if (files && files.length > 0) {
104
- resolve(GridFSHelper.normalizeFileInfo(files[0]));
148
+ succeed(GridFSHelper.normalizeFileInfo(files[0]));
105
149
  } else {
106
- reject(new Error('File uploaded but metadata not found'));
150
+ fail(new Error('File uploaded but metadata not found'));
107
151
  }
108
152
  })
109
- .catch(reject);
153
+ .catch(fail);
110
154
  });
111
155
 
112
156
  stream.pipe(uploadStream);
@@ -162,13 +206,54 @@ export class GridFSHelper {
162
206
  }
163
207
 
164
208
  /**
165
- * Find files with filter and options
209
+ * Find files with filter and options.
210
+ *
211
+ * The `contentType` key is rewritten to `metadata.contentType` in BOTH the
212
+ * filter and the sort — the exact mirror image of what
213
+ * {@link GridFSHelper.normalizeFileInfo} does on the way out.
214
+ *
215
+ * Without it, `contentType` was the one field where the three storage drivers
216
+ * were not equivalent: `s3-files` / `filesystem-files` carry it at the root of
217
+ * the document, GridFS keeps it inside `metadata` (the driver dropped the
218
+ * top-level option in mongodb 7). A `findFileInfo()` filtered on `contentType`
219
+ * therefore matched every S3 / filesystem file and NO GridFS file — silently
220
+ * returning nothing at all on the default driver, and silently dropping the
221
+ * pre-switch files on any other. That is exactly the "switching drivers is
222
+ * forward-only, no migration" promise failing for one field.
166
223
  */
167
224
  static async findFiles(bucket: GridFSBucket, filter: any = {}, options: any = {}): Promise<GridFSFileInfo[]> {
168
- const files = await bucket.find(filter, options).toArray();
225
+ const query = GridFSHelper.mapContentTypeKeys(filter);
226
+ const findOptions =
227
+ options && options.sort ? { ...options, sort: GridFSHelper.mapContentTypeKeys(options.sort) } : options;
228
+ const files = await bucket.find(query, findOptions).toArray();
169
229
  return files.map((file) => GridFSHelper.normalizeFileInfo(file));
170
230
  }
171
231
 
232
+ /**
233
+ * Rename a top-level `contentType` key to `metadata.contentType`, recursing
234
+ * through the logical operators `generateFilterQuery()` can emit.
235
+ *
236
+ * Deliberately narrow: only that one key is touched, only where it names a
237
+ * field, and an already-qualified `metadata.contentType` is left alone. Nothing
238
+ * else about the query is interpreted.
239
+ */
240
+ private static mapContentTypeKeys(value: any): any {
241
+ if (Array.isArray(value)) {
242
+ return value.map((entry) => GridFSHelper.mapContentTypeKeys(entry));
243
+ }
244
+ if (!value || typeof value !== 'object' || value instanceof RegExp || value instanceof Date) {
245
+ return value;
246
+ }
247
+ const mapped: Record<string, any> = {};
248
+ for (const [key, entry] of Object.entries(value)) {
249
+ // Only the logical operators hold nested FIELD maps; `$gt`, `$in`, `$regex`
250
+ // and friends hold VALUES, which must be passed through untouched.
251
+ const nested = key === '$and' || key === '$nor' || key === '$or' ? GridFSHelper.mapContentTypeKeys(entry) : entry;
252
+ mapped[key === 'contentType' ? 'metadata.contentType' : key] = nested;
253
+ }
254
+ return mapped;
255
+ }
256
+
172
257
  /**
173
258
  * Delete a file from GridFS
174
259
  */
@@ -0,0 +1,83 @@
1
+ import { ValidationArguments } from 'class-validator';
2
+
3
+ /**
4
+ * Renders a single constraint for use in an error message.
5
+ *
6
+ * Mirror of class-validator's internal `constraintToString`
7
+ * (`class-validator/cjs/validation/ValidationUtils`), which is NOT part of its public API.
8
+ *
9
+ * See {@link replaceMessageSpecialTokens} for why this lives here and what holds it in sync.
10
+ */
11
+ export function constraintToString(constraint: unknown): string {
12
+ if (Array.isArray(constraint)) {
13
+ return constraint.join(', ');
14
+ }
15
+ if (typeof constraint === 'symbol') {
16
+ constraint = constraint.description;
17
+ }
18
+ return `${constraint}`;
19
+ }
20
+
21
+ /**
22
+ * Resolves a message (string or function) and interpolates the special tokens
23
+ * $constraint1..N, $value, $property and $target.
24
+ *
25
+ * Mirror of class-validator's internal `ValidationUtils.replaceMessageSpecialTokens`
26
+ * (`class-validator/cjs/validation/ValidationUtils`), which is NOT part of its public API.
27
+ * `MapAndValidatePipe` re-implements the validation executor and therefore has to render messages
28
+ * exactly the way class-validator's own `validate()` would — otherwise the same decorator produces
29
+ * two different strings depending on which of the two ran.
30
+ *
31
+ * WHY IT IS NOT BARREL-EXPORTED
32
+ * Deliberately absent from `src/index.ts`. It exists to reproduce a dependency's internal
33
+ * behaviour, so it has to stay free to follow that dependency; exporting it would owe consumers
34
+ * backward compatibility for a surface we do not control. Vendor-mode consumers resolve their own
35
+ * class-validator, so the copy in `src/core/` can meet a version this repo never installed —
36
+ * `tests/unit/validation-message-mirror.spec.ts` compares it against the INSTALLED implementation
37
+ * over a fixture table so a divergence surfaces as a failing test rather than as two renderers of
38
+ * one contract.
39
+ *
40
+ * SECURITY — `$value` echoes the SUBMITTED value back to the client, and validation errors are
41
+ * never scrubbed: `security.secretFields` is applied by `CheckSecurityInterceptor` on the RESPONSE
42
+ * path, whereas the `BadRequestException` raised from the pipe goes to the exception filter and is
43
+ * forwarded verbatim. The check below is a TYPE guard (boolean | number | string), not a secrecy
44
+ * guard — it happily admits a password or a token. No built-in message uses `$value`, so reaching
45
+ * it is opt-in: never put `$value` in a custom message on a sensitive field.
46
+ */
47
+ export function replaceMessageSpecialTokens(
48
+ message: ((args: ValidationArguments) => string) | string,
49
+ validationArguments: ValidationArguments,
50
+ ): string {
51
+ let messageString = '';
52
+ if (typeof message === 'function') {
53
+ messageString = message(validationArguments);
54
+ } else if (typeof message === 'string') {
55
+ messageString = message;
56
+ }
57
+
58
+ if (messageString && Array.isArray(validationArguments.constraints)) {
59
+ validationArguments.constraints.forEach((constraint, index) => {
60
+ messageString = messageString.replace(
61
+ new RegExp(`\\$constraint${index + 1}`, 'g'),
62
+ constraintToString(constraint),
63
+ );
64
+ });
65
+ }
66
+
67
+ if (
68
+ messageString &&
69
+ validationArguments.value !== undefined &&
70
+ validationArguments.value !== null &&
71
+ ['boolean', 'number', 'string'].includes(typeof validationArguments.value)
72
+ ) {
73
+ messageString = messageString.replace(/\$value/g, `${validationArguments.value}`);
74
+ }
75
+ if (messageString) {
76
+ messageString = messageString.replace(/\$property/g, validationArguments.property);
77
+ }
78
+ if (messageString) {
79
+ messageString = messageString.replace(/\$target/g, validationArguments.targetName);
80
+ }
81
+
82
+ return messageString;
83
+ }
@@ -20,6 +20,7 @@ import {
20
20
  maxLength,
21
21
  min,
22
22
  minLength,
23
+ ValidationArguments,
23
24
  ValidationError,
24
25
  } from 'class-validator';
25
26
  import { ValidationMetadata } from 'class-validator/types/metadata/ValidationMetadata';
@@ -27,6 +28,7 @@ import { inspect } from 'util';
27
28
 
28
29
  import { getUnifiedFieldKeys, nestedTypeRegistry } from '../decorators/unified-field.decorator';
29
30
  import { isBasicType } from '../helpers/input.helper';
31
+ import { replaceMessageSpecialTokens } from '../helpers/validation-message.helper';
30
32
  import { ConfigService } from '../services/config.service';
31
33
  import { ErrorCode } from '../../modules/error-code/error-codes';
32
34
 
@@ -268,18 +270,21 @@ async function validateWithInheritance(object: any, originalPlainValue: any): Pr
268
270
  isValid = validationResult instanceof Promise ? await validationResult : validationResult;
269
271
  }
270
272
 
271
- // Get default message and constraint name if validation failed
273
+ // Get message and constraint name if validation failed
272
274
  if (!isValid) {
273
275
  // Use metadata.name for the constraint key (e.g., "isEmail", "isString")
274
276
  const constraintName = metadata.name || 'customValidation';
275
277
 
276
- if (typeof constraintInstance.defaultMessage === 'function') {
277
- errorMessage = constraintInstance.defaultMessage(validationArgs);
278
- // Replace $property placeholder with actual property name
279
- errorMessage = errorMessage.replace(/\$property/g, propertyName);
280
- } else {
281
- errorMessage = `${propertyName} failed custom validation`;
278
+ // A custom message from ValidationOptions takes precedence over the
279
+ // constraint's default message — same order as class-validator's executor
280
+ let messageTemplate: string | ((args: ValidationArguments) => string) | undefined =
281
+ metadata.message as string | ((args: ValidationArguments) => string) | undefined;
282
+ if (!messageTemplate && typeof constraintInstance.defaultMessage === 'function') {
283
+ messageTemplate = constraintInstance.defaultMessage(validationArgs);
282
284
  }
285
+ errorMessage = messageTemplate
286
+ ? replaceMessageSpecialTokens(messageTemplate, validationArgs)
287
+ : `${propertyName} failed custom validation`;
283
288
 
284
289
  // Add to constraints with the proper name
285
290
  propertyError.constraints[constraintName] = errorMessage;
@@ -578,6 +583,19 @@ async function validateWithInheritance(object: any, originalPlainValue: any): Pr
578
583
 
579
584
  // Add constraint violation if validation failed
580
585
  if (!isValid) {
586
+ // A custom message from ValidationOptions takes precedence over the built-in message
587
+ if (metadata.message) {
588
+ errorMessage = replaceMessageSpecialTokens(
589
+ metadata.message as string | ((args: ValidationArguments) => string),
590
+ {
591
+ constraints: metadata.constraints || [],
592
+ object: tempInstance,
593
+ property: propertyName,
594
+ targetName: targetClass.name,
595
+ value: propertyValue,
596
+ },
597
+ );
598
+ }
581
599
  propertyError.constraints[constraintType] = errorMessage;
582
600
  }
583
601
  }
@@ -1,4 +1,5 @@
1
1
  import { Injectable, Logger, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
2
+ import { PassThrough } from 'stream';
2
3
 
3
4
  import { buildContentDisposition } from '../helpers/content-disposition.helper';
4
5
  import { ConfigService } from './config.service';
@@ -276,15 +277,76 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
276
277
  length = (body as Buffer).length;
277
278
  }
278
279
 
279
- await client.send(
280
- new sdk.PutObjectCommand({
281
- Body: payload,
282
- Bucket: config.bucket,
283
- ...(contentType ? { ContentType: contentType } : {}),
284
- ...(length === undefined ? {} : { ContentLength: length }),
285
- Key: key,
286
- }),
287
- );
280
+ const send = () =>
281
+ client.send(
282
+ new sdk.PutObjectCommand({
283
+ Body: payload,
284
+ Bucket: config.bucket,
285
+ ...(contentType ? { ContentType: contentType } : {}),
286
+ ...(length === undefined ? {} : { ContentLength: length }),
287
+ Key: key,
288
+ }),
289
+ );
290
+
291
+ // A stream handed straight to the SDK is the ONE body shape whose failure is not already
292
+ // ours: the unknown-length branch above reads the stream itself (`for await` throws), and a
293
+ // Buffer cannot fail. See `guardBodyStream` for what goes wrong without this.
294
+ if (this.isStream(payload)) {
295
+ const guard = this.guardBodyStream(payload);
296
+ payload = guard.body;
297
+ try {
298
+ await send();
299
+ } catch (error) {
300
+ throw guard.sourceError() ?? error;
301
+ }
302
+ // A source that died after the SDK already considered the request done would otherwise be
303
+ // reported as a successful upload of a truncated object.
304
+ const failure = guard.sourceError();
305
+ if (failure) {
306
+ throw failure;
307
+ }
308
+ return;
309
+ }
310
+ await send();
311
+ }
312
+
313
+ /**
314
+ * Shield the SDK from a REQUEST-BODY stream that can fail, and keep the cause.
315
+ *
316
+ * WHY THIS EXISTS: the AWS SDK pipes the body into its HTTP request without listening on the
317
+ * SOURCE. An error there therefore reached a stream with NO listener, and Node turns an unhandled
318
+ * `'error'` event into an uncaught exception that ends the process. Reachable from ordinary
319
+ * operation — this is the path a tus upload takes when it is migrated into S3 (`body` plus a
320
+ * known `contentLength`), so a staged read that drops mid-migration took the API down with it.
321
+ * It is also a driver asymmetry of the shape this release keeps finding: the filesystem driver
322
+ * routes the same stream through `pipeline()`, which forwards both ends, and GridFS has its own
323
+ * source handler. Only S3 had none.
324
+ *
325
+ * WHY A RELAY RATHER THAN JUST A LISTENER: a listener alone stops the uncaught exception, but the
326
+ * SDK's own request pipeline still observes a body stream that ERRORED and rejects a promise of
327
+ * its own that nothing awaits — an unhandled rejection, which Node also treats as fatal by
328
+ * default. So the SDK must never see a failing stream at all. It gets a `PassThrough` that simply
329
+ * ENDS early instead; short of its declared `Content-Length`, that is an ordinary request failure
330
+ * the SDK reports through the promise we are already awaiting. `sourceError()` then replaces the
331
+ * SDK's `socket hang up` with the actual cause, which is the part an operator needs.
332
+ *
333
+ * A `Promise.race` against the send would be the obvious alternative and is worse: it introduces
334
+ * a second promise for the same failure, and whichever loses the race is a rejection nobody
335
+ * consumes — trading the uncaught exception for exactly the unhandled rejection above.
336
+ */
337
+ protected guardBodyStream(stream: Readable): { body: Readable; sourceError: () => Error | undefined } {
338
+ const relay = new PassThrough();
339
+ let sourceError: Error | undefined;
340
+
341
+ stream.on('error', (error: Error) => {
342
+ sourceError = error;
343
+ // GRACEFUL end, never `destroy(error)`: propagating the error into the relay would hand the
344
+ // SDK the failing stream this method exists to keep away from it.
345
+ relay.end();
346
+ });
347
+ stream.pipe(relay);
348
+
349
+ return { body: relay, sourceError: () => sourceError };
288
350
  }
289
351
 
290
352
  /**
@@ -93,6 +93,26 @@ Reads consult **every** store, so files written under a previous driver stay rea
93
93
  migration step and no cut-over moment. New files go to the active driver; `findFileInfo()` returns
94
94
  the union, paged once over the merged result.
95
95
 
96
+ That holds per method and in **both** directions — adopting a driver and switching away from one.
97
+ The metadata lookups are gated on whether a store is USABLE, never on whether it is the active write
98
+ driver, so pinning `file.storage: 'gridfs'` on a deployment that once used S3 keeps the S3 files
99
+ readable rather than 404-ing them.
100
+
101
+ Three consequences worth knowing about the merged read path:
102
+
103
+ - **Ordering.** Each store answers its own correctly ordered page, and the merge re-establishes a
104
+ global order before it pages (`sortMergedFileInfo()`), including dotted sort fields such as
105
+ `metadata.ownerId`. Without a `sort` the merged page is `uploadDate` descending.
106
+ - **`contentType`.** GridFS keeps it inside `metadata`, the other two stores at the document root.
107
+ `GridFSHelper.findFiles()` rewrites the key so a filter or sort on `contentType` behaves the same
108
+ under every driver.
109
+ - **Duplication crosses drivers.** `duplicateByName()` / `duplicateById()` read the source from
110
+ whichever store holds it and write the copy to the ACTIVE driver, so duplicating a file that is
111
+ still in GridFS while `file.storage: 's3'` simply moves it forward.
112
+
113
+ `s3-files` and `filesystem-files` are created on their first WRITE, never on a read — a deployment
114
+ that only ever uses GridFS never grows them.
115
+
96
116
  The boot log names the driver in use, so it never has to be inferred from where files stopped
97
117
  appearing:
98
118
 
@@ -184,6 +204,20 @@ rather than exempt from it. The reference server does both: `src/server/modules/
184
204
  `src/server/modules/user/avatar.controller.ts`. The contract test for the whole rule, covering the
185
205
  `id` **and** the `filename` branch, lives in `tests/file-ownership.e2e-spec.ts`.
186
206
 
207
+ **It covers duplication too (11.34.0+).** `duplicateByName()` / `duplicateById()` authorize a copy as
208
+ a READ of the source (`'filename'` / `'id'`) plus a WRITE of the copy (`'file'`), through the same
209
+ public methods every other caller uses — so forward the context:
210
+
211
+ ```typescript
212
+ await this.fileService.duplicateById(id, { currentUser });
213
+ // …and give the COPY its own owner, because it does NOT inherit the source's metadata
214
+ await this.fileService.duplicateById(id, { currentUser, metadata: { ownerId: currentUser.id } });
215
+ ```
216
+
217
+ Not copying the source's `metadata` is deliberate: doing so would silently hand the duplicate the
218
+ source's owner. A copy made without metadata is ADMIN-only under the rule above — fail-closed, not
219
+ lost.
220
+
187
221
  Three pieces make this work, and all three are needed:
188
222
 
189
223
  1. **Write the metadata at upload time** — `createFile(file, { metadata: { ownerId: user.id } })`.
@@ -325,12 +359,14 @@ export class FileController extends CoreFileController {
325
359
 
326
360
  ### Error responses
327
361
 
328
- | Situation | Status | Body |
329
- | -------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------- |
330
- | Unknown id / filename, or `checkRights()` refused | `404` | `NotFoundException` with `ErrorCode.FILE_NOT_FOUND` |
331
- | Missing id / filename in the route | `400` | `BadRequestException` with `ErrorCode.REQUIRED_FIELD_MISSING` |
332
- | GridFS read fails **before** any byte was sent (file document exists, chunks are gone) | `404` | `{ "error": "Not Found", "message": "<FILE_NOT_FOUND>", "statusCode": 404 }` |
333
- | GridFS read fails **after** streaming started | | The connection is closed; a truncated transfer is the only signal left once the status is on the wire |
362
+ | Situation | Status | Body |
363
+ | -------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
364
+ | Unknown id / filename, or `checkRights()` refused | `404` | `NotFoundException` with `ErrorCode.FILE_NOT_FOUND` |
365
+ | `deleteFile()` / `deleteFileByName()` / `duplicate*()` on a file that is not there | `404` | `NotFoundException`. Both halves of each id/name pair answer identically — up to 11.33.1 the by-id delete surfaced the driver's `MongoRuntimeError` as a `500` |
366
+ | Missing id / filename in the route | `400` | `BadRequestException` with `ErrorCode.REQUIRED_FIELD_MISSING` |
367
+ | An upload's SOURCE stream fails (client aborts, staged read drops) | `500` | The promise rejects with the cause under **every** driver. It used to be an uncaught exception (i.e. a process exit) on GridFS, and on the S3 streaming path tus finalization uses |
368
+ | GridFS read fails **before** any byte was sent (file document exists, chunks are gone) | `404` | `{ "error": "Not Found", "message": "<FILE_NOT_FOUND>", "statusCode": 404 }` |
369
+ | GridFS read fails **after** streaming started | — | The connection is closed; a truncated transfer is the only signal left once the status is on the wire |
334
370
 
335
371
  The mid-stream failure case is handled by `pipeFileToResponse()`. Without it the stream error would
336
372
  go unhandled, Node would destroy the socket, and a reverse proxy would report **502 Bad Gateway** —