@lenne.tech/nest-server 11.33.0 → 11.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.claude/rules/testing.md +181 -3
  2. package/FRAMEWORK-API.md +1 -1
  3. package/dist/config.env.js +6 -0
  4. package/dist/config.env.js.map +1 -1
  5. package/dist/core/common/helpers/gridfs.helper.d.ts +1 -0
  6. package/dist/core/common/helpers/gridfs.helper.js +43 -5
  7. package/dist/core/common/helpers/gridfs.helper.js.map +1 -1
  8. package/dist/core/common/services/core-s3.service.d.ts +4 -0
  9. package/dist/core/common/services/core-s3.service.js +28 -1
  10. package/dist/core/common/services/core-s3.service.js.map +1 -1
  11. package/dist/core/modules/file/core-file.service.d.ts +4 -2
  12. package/dist/core/modules/file/core-file.service.js +47 -48
  13. package/dist/core/modules/file/core-file.service.js.map +1 -1
  14. package/dist/core/modules/hub/helpers/hub-client-js.helper.js +12 -1
  15. package/dist/core/modules/hub/helpers/hub-client-js.helper.js.map +1 -1
  16. package/dist/core/modules/hub/hub-action-messages.d.ts +1 -0
  17. package/dist/core/modules/hub/hub-action-messages.js +1 -0
  18. package/dist/core/modules/hub/hub-action-messages.js.map +1 -1
  19. package/dist/core/modules/hub/interfaces/hub-panels.interface.d.ts +9 -0
  20. package/dist/core/modules/hub/services/core-hub-actions.service.d.ts +2 -0
  21. package/dist/core/modules/hub/services/core-hub-actions.service.js.map +1 -1
  22. package/dist/core/modules/hub/services/core-hub-db.service.d.ts +10 -2
  23. package/dist/core/modules/hub/services/core-hub-db.service.js +85 -29
  24. package/dist/core/modules/hub/services/core-hub-db.service.js.map +1 -1
  25. package/dist/server/modules/file/file.controller.js +2 -2
  26. package/dist/server/modules/file/file.controller.js.map +1 -1
  27. package/dist/server/modules/file/file.resolver.js +2 -2
  28. package/dist/server/modules/file/file.resolver.js.map +1 -1
  29. package/dist/server/modules/file/file.service.d.ts +7 -2
  30. package/dist/server/modules/file/file.service.js +13 -2
  31. package/dist/server/modules/file/file.service.js.map +1 -1
  32. package/dist/server/modules/user/avatar.controller.js +1 -1
  33. package/dist/server/modules/user/avatar.controller.js.map +1 -1
  34. package/dist/tsconfig.build.tsbuildinfo +1 -1
  35. package/docs/REQUEST-LIFECYCLE.md +1 -1
  36. package/migration-guides/11.23.x-to-11.24.0.md +1 -1
  37. package/migration-guides/11.32.x-to-11.33.x.md +39 -4
  38. package/migration-guides/11.33.x-to-11.34.x.md +394 -0
  39. package/package.json +3 -1
  40. package/src/config.env.ts +45 -0
  41. package/src/core/common/helpers/gridfs.helper.ts +92 -7
  42. package/src/core/common/services/core-s3.service.ts +71 -9
  43. package/src/core/modules/file/INTEGRATION-CHECKLIST.md +16 -4
  44. package/src/core/modules/file/README.md +77 -10
  45. package/src/core/modules/file/core-file.service.ts +170 -64
  46. package/src/core/modules/hub/README.md +35 -9
  47. package/src/core/modules/hub/helpers/hub-client-js.helper.ts +12 -1
  48. package/src/core/modules/hub/hub-action-messages.ts +9 -1
  49. package/src/core/modules/hub/interfaces/hub-panels.interface.ts +31 -1
  50. package/src/core/modules/hub/services/core-hub-actions.service.ts +10 -2
  51. package/src/core/modules/hub/services/core-hub-db.service.ts +155 -31
  52. package/src/server/modules/file/file.controller.ts +7 -2
  53. package/src/server/modules/file/file.resolver.ts +6 -2
  54. package/src/server/modules/file/file.service.ts +97 -34
  55. package/src/server/modules/user/avatar.controller.ts +8 -1
@@ -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
  */
@@ -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
  /**
@@ -65,8 +65,18 @@ decision worth making explicitly.
65
65
  caller have THIS file". For the latter, write an owner or tenant into the metadata at upload time
66
66
  (`createFile(file, { metadata: { ownerId } })`) and compare it in `checkRights()` via
67
67
  `getRawFileInfo()` / `getRawFileInfoByName()`. The public `getFileInfo()` strips restricted fields
68
- and is unusable for the decision. `getRawFileInfo()` checks S3 metadata first, then GridFS, so the
69
- same rule works under either `file.storage`.
68
+ and is unusable for the decision. Both raw lookups consult every store — S3 metadata first, then the
69
+ filesystem store, then GridFS — so the same rule works under any `file.storage`.
70
+
71
+ Handle **both** `checkInputType: 'id'` and `'filename'`. An id-only rule is enough while bytes are
72
+ streamed (the filename route resolves an id and re-checks it), but not with presigned S3 downloads,
73
+ and not for `deleteFileByName()`, which authorizes by name only.
74
+
75
+ **Copy from the executed reference, not from prose:** `src/server/modules/file/file.service.ts`
76
+ implements exactly this rule, and `src/config.env.ts` widens `file.downloadRoles` to `[S_USER]` so
77
+ it is actually reached. Note the internal callers there too — `{ force: true }` on the
78
+ `@Roles(ADMIN)` endpoints, and a real `{ currentUser }` in `AvatarController` — because a rule that
79
+ reads a missing user as "internal, allow" fails open the moment the coarse gate is widened.
70
80
 
71
81
  ### 5. Decide how the frontend fetches files
72
82
 
@@ -82,8 +92,10 @@ route for exactly the public files and leave the core routes gated.
82
92
 
83
93
  - [ ] `pnpm run build` succeeds
84
94
  - [ ] Anonymous `GET /files/id/<id>` answers **401**
85
- - [ ] A signed-in non-privileged user answers **403** (not 401 a 401 makes SPA auth layers log the
86
- user out)
95
+ - [ ] A signed-in non-privileged user answers **403** when the ROLE GATE is what refuses (not 401
96
+ a 401 makes SPA auth layers log the user out). If you widened `downloadRoles` and let
97
+ `checkRights()` decide instead, the expected answer is **404**, byte-identical to an unknown
98
+ id — a 403 there would confirm the file exists
87
99
  - [ ] A caller holding a configured role downloads successfully
88
100
  - [ ] If you set `file.downloadRoles`, the value actually takes effect — if it does not, something
89
101
  in your controller is overriding the member (see step 2)
@@ -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
 
@@ -141,18 +161,63 @@ export class FileService extends CoreFileService {
141
161
  input: any,
142
162
  options?: FileServiceOptions & { checkInputType: FileInputCheckType },
143
163
  ): Promise<boolean> {
144
- if (options?.checkInputType !== 'id' || options.force) {
164
+ // Writes, list queries and forced (system) calls stay on the coarse role gate
165
+ if (options?.force || (options?.checkInputType !== 'filename' && options?.checkInputType !== 'id')) {
145
166
  return true;
146
167
  }
147
- if (options.currentUser?.hasRole([RoleEnum.ADMIN])) {
168
+ if (options.currentUser?.hasRole?.([RoleEnum.ADMIN])) {
148
169
  return true;
149
170
  }
150
- const raw = await this.getRawFileInfo(input);
151
- return !!raw && String(raw.metadata?.ownerId) === String(options.currentUser?.id);
171
+ const raw =
172
+ options.checkInputType === 'id' ? await this.getRawFileInfo(input) : await this.getRawFileInfoByName(input);
173
+ // Fails closed without a user, and on a file that records no owner.
174
+ return !!raw?.metadata?.ownerId && String(raw.metadata.ownerId) === String(options.currentUser?.id);
152
175
  }
153
176
  }
154
177
  ```
155
178
 
179
+ This is not a sketch: it is the rule `src/server/modules/file/file.service.ts` runs, with
180
+ `file: { downloadRoles: [RoleEnum.S_USER] }` in `src/config.env.ts` so the coarse gate actually
181
+ lets it fire. It used to live here and in that file as a **comment**, on the reasoning that the
182
+ `[ADMIN]` default made it unreachable in the reference server anyway — and a commented rule is never
183
+ compiled, never type-checked and never run. That is how a `deleteFileByName()` regression on the
184
+ `filename` branch shipped through a full green suite.
185
+
186
+ **Cover the `filename` branch too, not just `id`.** An id-only rule is enough while bytes are
187
+ streamed, because the filename route resolves an id and checks it again — but not once
188
+ `file.storage: 's3'` with presigned downloads is enabled, where the filename route authorizes on the
189
+ by-name lookup alone and then redirects, and not for `deleteFileByName()`, which authorizes by name
190
+ only.
191
+
192
+ **Never add `if (!options.currentUser) return true`.** It reads as "system-internal call, the guard
193
+ already decided" — but "no user in context" is also exactly what an **anonymous** request looks like.
194
+ While `downloadRoles` is narrower than `S_EVERYONE` the role gate turns those away first, so the
195
+ branch looks harmless; widen the gate, which this very section invites you to do, and it hands every
196
+ file to everyone. The ownership rule evaporates precisely when it starts to matter. The same reason
197
+ makes `!!raw?.metadata?.ownerId` load-bearing: without it, an owner-less file compares
198
+ `String(undefined)` against `String(undefined)` and matches.
199
+
200
+ Callers that really are internal should say so instead of relying on the omission — `{ force: true }`
201
+ where a role decorator already decided (an `@Roles(ADMIN)` admin endpoint), or the real
202
+ `{ currentUser }` where the user is in scope, so that call is **covered** by the ownership rule
203
+ rather than exempt from it. The reference server does both: `src/server/modules/file/` and
204
+ `src/server/modules/user/avatar.controller.ts`. The contract test for the whole rule, covering the
205
+ `id` **and** the `filename` branch, lives in `tests/file-ownership.e2e-spec.ts`.
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
+
156
221
  Three pieces make this work, and all three are needed:
157
222
 
158
223
  1. **Write the metadata at upload time** — `createFile(file, { metadata: { ownerId: user.id } })`.
@@ -294,12 +359,14 @@ export class FileController extends CoreFileController {
294
359
 
295
360
  ### Error responses
296
361
 
297
- | Situation | Status | Body |
298
- | -------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------- |
299
- | Unknown id / filename, or `checkRights()` refused | `404` | `NotFoundException` with `ErrorCode.FILE_NOT_FOUND` |
300
- | Missing id / filename in the route | `400` | `BadRequestException` with `ErrorCode.REQUIRED_FIELD_MISSING` |
301
- | GridFS read fails **before** any byte was sent (file document exists, chunks are gone) | `404` | `{ "error": "Not Found", "message": "<FILE_NOT_FOUND>", "statusCode": 404 }` |
302
- | 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 |
303
370
 
304
371
  The mid-stream failure case is handled by `pipeFileToResponse()`. Without it the stream error would
305
372
  go unhandled, Node would destroy the socket, and a reverse proxy would report **502 Bad Gateway** —