@lenne.tech/nest-server 11.33.1 → 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.
- package/.claude/rules/testing.md +181 -3
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/common/helpers/gridfs.helper.d.ts +1 -0
- package/dist/core/common/helpers/gridfs.helper.js +43 -5
- package/dist/core/common/helpers/gridfs.helper.js.map +1 -1
- package/dist/core/common/services/core-s3.service.d.ts +4 -0
- package/dist/core/common/services/core-s3.service.js +28 -1
- package/dist/core/common/services/core-s3.service.js.map +1 -1
- package/dist/core/modules/file/core-file.service.d.ts +4 -2
- package/dist/core/modules/file/core-file.service.js +46 -47
- package/dist/core/modules/file/core-file.service.js.map +1 -1
- package/dist/core/modules/hub/helpers/hub-client-js.helper.js +12 -1
- package/dist/core/modules/hub/helpers/hub-client-js.helper.js.map +1 -1
- package/dist/core/modules/hub/hub-action-messages.d.ts +1 -0
- package/dist/core/modules/hub/hub-action-messages.js +1 -0
- package/dist/core/modules/hub/hub-action-messages.js.map +1 -1
- package/dist/core/modules/hub/interfaces/hub-panels.interface.d.ts +9 -0
- package/dist/core/modules/hub/services/core-hub-actions.service.d.ts +2 -0
- package/dist/core/modules/hub/services/core-hub-actions.service.js.map +1 -1
- package/dist/core/modules/hub/services/core-hub-db.service.d.ts +10 -2
- package/dist/core/modules/hub/services/core-hub-db.service.js +85 -29
- package/dist/core/modules/hub/services/core-hub-db.service.js.map +1 -1
- package/dist/server/modules/file/file.service.d.ts +2 -1
- package/dist/server/modules/file/file.service.js +2 -2
- package/dist/server/modules/file/file.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/migration-guides/11.23.x-to-11.24.0.md +1 -1
- package/migration-guides/11.32.x-to-11.33.x.md +2 -0
- package/migration-guides/11.33.x-to-11.34.x.md +394 -0
- package/package.json +3 -1
- package/src/core/common/helpers/gridfs.helper.ts +92 -7
- package/src/core/common/services/core-s3.service.ts +71 -9
- package/src/core/modules/file/README.md +42 -6
- package/src/core/modules/file/core-file.service.ts +131 -59
- package/src/core/modules/hub/README.md +35 -9
- package/src/core/modules/hub/helpers/hub-client-js.helper.ts +12 -1
- package/src/core/modules/hub/hub-action-messages.ts +9 -1
- package/src/core/modules/hub/interfaces/hub-panels.interface.ts +31 -1
- package/src/core/modules/hub/services/core-hub-actions.service.ts +10 -2
- package/src/core/modules/hub/services/core-hub-db.service.ts +155 -31
- package/src/server/modules/file/file.service.ts +20 -3
|
@@ -267,6 +267,6 @@ If your code accessed `result.author.password` after a CrudService call (within
|
|
|
267
267
|
|
|
268
268
|
## References
|
|
269
269
|
|
|
270
|
-
-
|
|
270
|
+
- SubDocument array handling (`pushToArray()` / `pullFromArray()`, and why a subdoc array must never pass through `CrudService.update()`) — see `CLAUDE.md` § High-Frequency Path Design Rules, rule 6. The former `docs/subdocument-array-optimization-plan.md` was a working document and no longer exists
|
|
271
271
|
- [process() Performance Optimization](../docs/process-performance-optimization.md) — Pipeline optimization details
|
|
272
272
|
- [nest-server-starter](https://github.com/lenneTech/nest-server-starter) — Reference implementation
|
|
@@ -1297,6 +1297,8 @@ re-resolve. Update the lockfile and re-run `pnpm audit` rather than assuming the
|
|
|
1297
1297
|
| Rate limiting enabled **behind a reverse proxy** | **Action required** | Set `trustProxy` (hop count) or every client shares one bucket — §11c. A boot warning names it |
|
|
1298
1298
|
| Rate limiting enabled with **nothing in front of the app** | **Unchanged**, plus a boot warning | Set `trustProxy: false` to state it explicitly and silence the warning |
|
|
1299
1299
|
| Project overriding `CoreFileService.getFileInfo()` | **Action required for `GET /files/id/:id`** | That route now resolves through `resolveFile()`; override it too — §14 |
|
|
1300
|
+
| Project calling `duplicateByName()` / `duplicateById()` | **Action required in 11.34.0** | They take an optional `serviceOptions` and are authorized now, and `duplicateByName()` resolves a `CoreFileInfo` instead of a write stream — [11.33.x → 11.34.x](./11.33.x-to-11.34.x.md) §1 |
|
|
1301
|
+
| Test asserting on the error of `deleteFile(<unknown id>)` | **Action required in 11.34.0** | `NotFoundException` (404) instead of `MongoRuntimeError` (500) — [11.33.x → 11.34.x](./11.33.x-to-11.34.x.md) §3 |
|
|
1300
1302
|
| Project calling `findFileInfo()` **without** a `sort`, with `file.storage` other than `gridfs` | **Ordering changed** | The merged multi-store page defaults to `uploadDate` descending — §14 |
|
|
1301
1303
|
| Overridden Hub collector `getData()` / mailbox reads | **Action required** | Await and widen the return type (§2) |
|
|
1302
1304
|
| `const s: GridFSBucketReadStream = await getFileStream(...)` | **Action required** | Type is `Readable` now (§3) |
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
# Migration Guide: 11.33.x → 11.34.x
|
|
2
|
+
|
|
3
|
+
## Why this is a MINOR
|
|
4
|
+
|
|
5
|
+
Per `.claude/rules/versioning.md` the MAJOR mirrors NestJS, so **MINOR is where breaking changes
|
|
6
|
+
live**. This release changes the declared return type of a public method (§1), so it is `11.34.0`
|
|
7
|
+
rather than a `11.33.2` patch — even though the change set is small and every other entry is a fix
|
|
8
|
+
you simply inherit.
|
|
9
|
+
|
|
10
|
+
## Upgrade urgency
|
|
11
|
+
|
|
12
|
+
Three of the eight defects fixed here are severe. If you are on 11.33.x, these are the reason to
|
|
13
|
+
move now rather than at the next convenient moment:
|
|
14
|
+
|
|
15
|
+
| # | Defect | Exposure | Section |
|
|
16
|
+
|---|--------|----------|---------|
|
|
17
|
+
| 1 | **Duplication bypassed `checkRights()` entirely** on the GridFS driver — a per-file ownership rule did not apply to `duplicateByName()` at all | Any project with an overridden `checkRights()` that exposes duplication. The copy was made with **no** authorization | [§1](#1-security-duplication-bypassed-checkrights-on-gridfs) |
|
|
18
|
+
| 2 | **An aborted upload crashed the process** — an unhandled stream `'error'` is an uncaught exception in Node | Every GridFS upload; and every tus finalization into S3. A client that hangs up mid-upload takes the API down | [§2](#2-crash-an-aborted-upload-no-longer-takes-the-process-down-gridfs-and-s3) |
|
|
19
|
+
| 3 | Same crash on the **S3** streaming write path | `tus` + `file.storage: 's3'` | [§2](#2-crash-an-aborted-upload-no-longer-takes-the-process-down-gridfs-and-s3) |
|
|
20
|
+
|
|
21
|
+
Both crash paths are reachable from unauthenticated-adjacent traffic (an upload that is simply cut
|
|
22
|
+
off), and neither leaves a stack trace pointing at the file module — they surface as an unexplained
|
|
23
|
+
pod restart. If you have been seeing those on a replica that accepts uploads, §2 is a strong
|
|
24
|
+
candidate.
|
|
25
|
+
|
|
26
|
+
## Overview
|
|
27
|
+
|
|
28
|
+
| Category | Details |
|
|
29
|
+
|----------|---------|
|
|
30
|
+
| **Security** | `duplicateByName()` no longer copies a file without a rights check on the GridFS driver (§1) |
|
|
31
|
+
| **Stability** | An upload whose SOURCE stream fails no longer crashes the process — on GridFS, and on the S3 driver's streaming (tus finalization) path (§2) |
|
|
32
|
+
| **Breaking Changes** | `duplicateByName()` resolves the COPY's `CoreFileInfo` instead of a raw `GridFSBucketWriteStream` (§1). `duplicateByName()` / `duplicateById()` now run `checkRights()` — a project with a fail-closed `checkRights()` that duplicated files without passing a context gets a refusal where the GridFS driver previously copied unchecked (§1). `deleteFile()` answers `NotFoundException` (404) for an unknown id instead of the driver's `MongoRuntimeError` (500) (§3) |
|
|
33
|
+
| **New Features** | `duplicateByName()` / `duplicateById()` accept an optional `serviceOptions` (§1). `CoreFileService.duplicateFile()` as the shared, driver-agnostic write half. The Hub's **Files panel covers all three storage drivers** (§6) |
|
|
34
|
+
| **Bugfixes** | `findFileInfo()` sorts correctly on a DOTTED path (`metadata.ownerId`) across merged stores — a wrong order there returned the wrong ROWS, not merely the wrong order (§4). `findFileInfo()` can filter and sort on `contentType` across all three drivers (§5). `duplicateByName()` keeps the source's content type on the GridFS driver |
|
|
35
|
+
| **Migration Effort** | Nothing to configure. Read §1 if you call `duplicateByName()` / `duplicateById()` anywhere, §3 if any test asserts on the error a delete-by-unknown-id produces. Everything else is a fix you inherit |
|
|
36
|
+
|
|
37
|
+
All of it lives in the file module, the GridFS helper, `CoreS3Service`'s upload path and the Hub's DB
|
|
38
|
+
service. If your project never calls the duplicate methods, the only changes you can observe are the
|
|
39
|
+
fixes — all of which turn a wrong answer into a right one.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Quick Migration
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pnpm update @lenne.tech/nest-server@11.34.0
|
|
47
|
+
pnpm run build
|
|
48
|
+
pnpm test
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Vendor-mode projects:** eight modified files under `src/core/`, in two independent groups. No
|
|
52
|
+
moved files and no new files, so a partial sync has no atomic file-set hazard — but the Hub group
|
|
53
|
+
must land together, because the panel, its data contract and its action message changed as one.
|
|
54
|
+
|
|
55
|
+
| Group | Files |
|
|
56
|
+
|-------|-------|
|
|
57
|
+
| File module (§1–§5) | `common/helpers/gridfs.helper.ts`, `common/services/core-s3.service.ts`, `modules/file/core-file.service.ts` |
|
|
58
|
+
| Hub Files panel (§6) | `modules/hub/services/core-hub-db.service.ts`, `modules/hub/services/core-hub-actions.service.ts`, `modules/hub/interfaces/hub-panels.interface.ts`, `modules/hub/hub-action-messages.ts`, `modules/hub/helpers/hub-client-js.helper.ts` |
|
|
59
|
+
|
|
60
|
+
The reference implementation in `src/server/modules/file/file.service.ts` also changed (§1), but
|
|
61
|
+
vendor projects do not receive `src/server/` — if you copied its old `duplicate()` body, see §1.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 1. Security: duplication bypassed `checkRights()` on GridFS
|
|
66
|
+
|
|
67
|
+
### What was wrong
|
|
68
|
+
|
|
69
|
+
Neither `duplicateByName()` nor `duplicateById()` accepted `serviceOptions` at all. They still called
|
|
70
|
+
`getFileInfo()`, `getFileStream()` and `createFile()` — all of which run `checkRights()` — but with an
|
|
71
|
+
**empty context**. Under the fail-closed `checkRights()` that this framework's README, its reference
|
|
72
|
+
server and `nest-server-starter` all teach, that produced three different wrong answers depending on
|
|
73
|
+
the storage driver:
|
|
74
|
+
|
|
75
|
+
| Call | `gridfs` | `filesystem` | `s3` |
|
|
76
|
+
|------|----------|--------------|------|
|
|
77
|
+
| `duplicateById(id)` | `TypeError: Cannot read properties of null (reading 'filename')` | same `TypeError` | same `TypeError` |
|
|
78
|
+
| `duplicateByName(a, b)` | **succeeded — the file was copied with no rights check at all** | `Error: FilesystemFileHelper.writeFile requires either 'buffer' or 'body'` | `TypeError: … (reading 'Symbol(Symbol.asyncIterator)')` |
|
|
79
|
+
|
|
80
|
+
The GridFS cell is the severe one, and it is the reason this release exists: that branch went
|
|
81
|
+
straight to the bucket and never consulted `checkRights()`, so a per-file ownership rule simply did
|
|
82
|
+
not apply to duplication. On the default storage driver, any caller who could reach a duplicate
|
|
83
|
+
endpoint could copy **any** file — and the copy, being a new file, was then reachable under whatever
|
|
84
|
+
rule the copy itself satisfied.
|
|
85
|
+
|
|
86
|
+
The other two cells are the same defect failing loudly instead of silently. That asymmetry is why it
|
|
87
|
+
survived: the drivers that would have made the bug obvious were the ones nobody ran.
|
|
88
|
+
|
|
89
|
+
### What it does now
|
|
90
|
+
|
|
91
|
+
A duplicate is treated as what it is — a **READ of the source plus a WRITE of the copy** — and each
|
|
92
|
+
half is authorized through the ordinary public method:
|
|
93
|
+
|
|
94
|
+
| Half | Method | `checkInputType` |
|
|
95
|
+
|------|--------|------------------|
|
|
96
|
+
| read the source | `getFileInfoByName()` / `getFileInfo()` + `getFileStreamByName()` / `getFileStream()` | `'filename'` / `'id'` |
|
|
97
|
+
| write the copy | `createFile()` | `'file'` |
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
// Forward the caller's context, so the duplicate is COVERED by your rule
|
|
101
|
+
await this.fileService.duplicateById(id, { currentUser });
|
|
102
|
+
await this.fileService.duplicateByName(name, newName, { currentUser });
|
|
103
|
+
|
|
104
|
+
// …or say explicitly that a role decorator already decided
|
|
105
|
+
await this.fileService.duplicateById(id, { force: true });
|
|
106
|
+
|
|
107
|
+
// …and give the COPY its own metadata (see below)
|
|
108
|
+
await this.fileService.duplicateById(id, { currentUser, metadata: { ownerId: currentUser.id } });
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
A missing source, or a refused read, now answers `NotFoundException` — the same 404 the download
|
|
112
|
+
routes answer, so a refusal never confirms that an id or a filename is real. A refused **write**
|
|
113
|
+
answers through `accessDeniedException()` (403 authenticated / 401 anonymous), because at that point
|
|
114
|
+
the caller has already been shown the source and the question is no longer whether the file exists.
|
|
115
|
+
|
|
116
|
+
### Break 1 — a fail-closed `checkRights()` now applies to duplication
|
|
117
|
+
|
|
118
|
+
This is the security fix seen from the consumer's side: if your project overrides `checkRights()` and
|
|
119
|
+
duplicates files **without** passing a context, the GridFS driver used to copy anyway. It now refuses.
|
|
120
|
+
|
|
121
|
+
**Symptom:** `NotFoundException: File not found with filename …` (or `… with id …`) from a code path
|
|
122
|
+
that worked before, and no copy in the bucket.
|
|
123
|
+
|
|
124
|
+
**Fix:** pass `{ currentUser }` where a user is in scope, or `{ force: true }` where a role decorator
|
|
125
|
+
has already decided. Do **not** add `if (!options.currentUser) return true` to `checkRights()` — that
|
|
126
|
+
reads "system-internal call", but it is also exactly what an anonymous request looks like.
|
|
127
|
+
|
|
128
|
+
### Break 2 — the return value of `duplicateByName()`
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
// 11.33.x and earlier, GridFS driver: the raw write stream
|
|
132
|
+
const stream = await fileService.duplicateByName('a.txt', 'b.txt');
|
|
133
|
+
stream.id; // ObjectId
|
|
134
|
+
|
|
135
|
+
// 11.34.0: the copy's file info, under every driver
|
|
136
|
+
const copy = await fileService.duplicateByName('a.txt', 'b.txt');
|
|
137
|
+
copy.id; // string
|
|
138
|
+
copy.filename; // 'b.txt'
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`.id` and `.filename` still answer (as a string and the new name), so the common uses keep working —
|
|
142
|
+
`String(result.id)` and `getStringIds(result.id)` are unaffected. Anything that treated the result as
|
|
143
|
+
a **stream** has to change. The declared return type moved from `Promise<any>` to
|
|
144
|
+
`Promise<CoreFileInfo>`, so TypeScript points at the sites for you. **This is the change that makes
|
|
145
|
+
the release a MINOR.**
|
|
146
|
+
|
|
147
|
+
This break is not avoidable: the GridFS branch could only be authorized by routing it through
|
|
148
|
+
`createFile()` like the other two drivers, and that is what produces a file info. The S3 and
|
|
149
|
+
filesystem branches already returned a `CoreFileInfo`, so this also removes a driver-conditional
|
|
150
|
+
return type that was never documented as one.
|
|
151
|
+
|
|
152
|
+
`duplicateById()` still returns the copy's id as a `string`. Unchanged.
|
|
153
|
+
|
|
154
|
+
### The copy does not inherit the source's metadata
|
|
155
|
+
|
|
156
|
+
It never did, and it deliberately still does not: silently copying `metadata` would hand the
|
|
157
|
+
duplicate the **source's owner**, which is the one thing an ownership rule must not do behind the
|
|
158
|
+
caller's back. State the copy's own metadata instead:
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
await this.fileService.duplicateById(id, { currentUser, metadata: { copiedFrom: id, ownerId: currentUser.id } });
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Under a fail-closed rule a copy made without metadata is reachable by ADMIN only — that is
|
|
165
|
+
fail-closed, not lost data, and `getFileInfo(copyId, { force: true })` still finds it.
|
|
166
|
+
|
|
167
|
+
### If you copied `FileService.duplicate()` out of `src/server`
|
|
168
|
+
|
|
169
|
+
The reference implementation was a direct GridFS pipe — and because it is a reference
|
|
170
|
+
implementation, this defect was **designed to be copied**:
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
// WRONG — and it was in the reference implementation until 11.34.0
|
|
174
|
+
async duplicate(fileName: string, newName: string): Promise<any> {
|
|
175
|
+
return this.files.openDownloadStreamByName(fileName).pipe(this.files.openUploadStream(newName));
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
It only worked on the GridFS driver, bypassed `checkRights()` entirely, returned before the copy was
|
|
180
|
+
written, and carried no error handler on either stream (an unhandled stream `'error'` takes the
|
|
181
|
+
process down — the same defect as §2). It now delegates:
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
async duplicate(fileName: string, newName: string, serviceOptions?: FileServiceOptions): Promise<CoreFileInfo> {
|
|
185
|
+
return this.duplicateByName(fileName, newName, serviceOptions);
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**If you copied the old body into your own `FileService`, replace it with the delegation above.** The
|
|
190
|
+
framework fix does not reach a copy living in your project.
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## 2. Crash: an aborted upload no longer takes the process down (GridFS **and** S3)
|
|
195
|
+
|
|
196
|
+
Two write paths handed a body stream to something that did not listen on the SOURCE. `pipe()` does
|
|
197
|
+
not forward source errors, and an unhandled `'error'` event is an uncaught exception in Node — which
|
|
198
|
+
ends the process. An aborted upload could take the API down.
|
|
199
|
+
|
|
200
|
+
| Write path | Reached by | Before | After |
|
|
201
|
+
|------------|-----------|--------|-------|
|
|
202
|
+
| `GridFSHelper.writeFileFromStream()` | every GridFS upload | **process crash** | rejects, partial upload aborted |
|
|
203
|
+
| `CoreS3Service.putObject()` with a body stream + `contentLength` | **tus finalization into S3** | **process crash** | rejects with the real cause |
|
|
204
|
+
| `CoreS3Service.putObject()` without a length | `createFile()` on the S3 driver | rejects (`streamToBuffer` throws) | unchanged |
|
|
205
|
+
| `FilesystemFileHelper.writeFile()` | every filesystem upload | rejects (`pipeline()` forwards both ends) | unchanged |
|
|
206
|
+
|
|
207
|
+
So the same aborted upload behaved in three different ways depending on `file.storage` — which is
|
|
208
|
+
the driver asymmetry this release keeps finding, in its most expensive form.
|
|
209
|
+
|
|
210
|
+
**GridFS.** The failed write is now aborted as well, so its chunks do not linger in `fs.chunks` with
|
|
211
|
+
no `fs.files` document naming them.
|
|
212
|
+
|
|
213
|
+
**S3.** The AWS SDK pipes the request body into its HTTP request without a source listener. Adding a
|
|
214
|
+
listener alone is *not* enough: the SDK's own pipeline still observes a stream that errored and
|
|
215
|
+
rejects a promise nothing awaits — an unhandled rejection, which Node also treats as fatal by
|
|
216
|
+
default. The body is therefore relayed through a `PassThrough` that simply **ends early** on source
|
|
217
|
+
failure, which the SDK reports as an ordinary short-body request error; the captured source error
|
|
218
|
+
then replaces the SDK's `socket hang up`, so the log names the cause rather than the symptom.
|
|
219
|
+
|
|
220
|
+
Nothing to do — but if you have been seeing unexplained restarts on a pod that accepts uploads (or
|
|
221
|
+
finalizes tus uploads into S3), this is the fix.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## 3. `deleteFile()` answers 404 for an unknown id, not 500
|
|
226
|
+
|
|
227
|
+
`deleteFileByName()` threw `NotFoundException` for a filename that does not exist. `deleteFile()` fell
|
|
228
|
+
through to the GridFS driver, which threw its own `MongoRuntimeError: File not found for id …` — an
|
|
229
|
+
HTTP **500** for the very same condition, under all three storage drivers.
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
await fileService.deleteFile(unknownId);
|
|
233
|
+
// 11.33.x: MongoRuntimeError → 500
|
|
234
|
+
// 11.34.0: NotFoundException → 404
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
A **refusal** still answers `null` rather than throwing, which is unchanged and is the module-wide
|
|
238
|
+
contract: a refusal must be indistinguishable from a file that is not there.
|
|
239
|
+
|
|
240
|
+
**What to check:** any test or client code that asserts on the error of a delete-by-unknown-id. If
|
|
241
|
+
you catch `MongoRuntimeError` there, catch `NotFoundException` instead — or, better, treat 404 as the
|
|
242
|
+
idempotent-delete case.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## 4. `findFileInfo()` sorts correctly on a dotted path across stores
|
|
247
|
+
|
|
248
|
+
`SortInput.field` is a free string, and MongoDB reads `metadata.ownerId` as a path into the document.
|
|
249
|
+
Each store therefore returned its own page correctly ordered — and the merge that follows compared
|
|
250
|
+
`doc['metadata.ownerId']`, which is `undefined` for every row. Every comparison tied, and the merged
|
|
251
|
+
page came back **grouped by store**.
|
|
252
|
+
|
|
253
|
+
That is not a cosmetic ordering bug: `skip` / `limit` applied to a wrongly ordered merge returns
|
|
254
|
+
**different rows**, so a paginated listing sorted on a nested field silently showed some files twice
|
|
255
|
+
and others never.
|
|
256
|
+
|
|
257
|
+
Only the multi-store read path was affected — i.e. any `file.storage` other than `gridfs`, or a
|
|
258
|
+
GridFS deployment that also has files in `s3-files` / `filesystem-files` from a previous driver.
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## 5. `findFileInfo()` can filter and sort on `contentType` under every driver
|
|
263
|
+
|
|
264
|
+
`s3-files` and `filesystem-files` carry `contentType` at the root of the metadata document. GridFS
|
|
265
|
+
keeps it inside `metadata` (the MongoDB driver dropped the top-level option in v7). So a filter on
|
|
266
|
+
`contentType` matched every S3 / filesystem file and **no** GridFS file:
|
|
267
|
+
|
|
268
|
+
| Driver | `findFileInfo({ filter: contentType == 'application/pdf' })` before | after |
|
|
269
|
+
|--------|--------------------------------------------------------------------|-------|
|
|
270
|
+
| `gridfs` | always `[]` | matches |
|
|
271
|
+
| `filesystem` / `s3` | matches new files, **silently drops every pre-switch GridFS file** | matches both |
|
|
272
|
+
|
|
273
|
+
`GridFSHelper.findFiles()` now rewrites a `contentType` key to `metadata.contentType` in both the
|
|
274
|
+
filter and the sort — the mirror image of the normalisation it already did on the way out. The
|
|
275
|
+
rewrite is deliberately narrow: only that one key, only where it names a field, recursing only
|
|
276
|
+
through `$and` / `$or` / `$nor`, and an already-qualified `metadata.contentType` is left alone.
|
|
277
|
+
|
|
278
|
+
**What you may notice:** a listing filtered on `contentType` that used to return nothing now returns
|
|
279
|
+
rows. If you depended on the empty result, filter on something else.
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## 6. The Hub's Files panel covers all three storage drivers
|
|
284
|
+
|
|
285
|
+
`CoreHubDbService.getFiles()` / `deleteFile()` read GridFS directly, so under `file.storage: 's3'` or
|
|
286
|
+
`'filesystem'` the Hub's Files panel reported **0 files** and could not delete anything.
|
|
287
|
+
|
|
288
|
+
"0 files" is not "this panel does not cover your driver" — in an operator cockpit a confidently wrong
|
|
289
|
+
answer is worse than an honest gap, because it is the answer an operator acts on. Somebody checking
|
|
290
|
+
whether an upload landed would have concluded it had not.
|
|
291
|
+
|
|
292
|
+
The panel now reads the same three metadata sources `CoreFileService.findFileInfo()` does
|
|
293
|
+
(`fs.files`, `s3-files`, `filesystem-files`), merges them newest-first, and dispatches a delete to
|
|
294
|
+
whichever store actually holds the file. Each row carries a **Store** column, and the panel's tiles
|
|
295
|
+
name the stores consulted with a per-store count.
|
|
296
|
+
|
|
297
|
+
| Behaviour | Before | After |
|
|
298
|
+
|-----------|--------|-------|
|
|
299
|
+
| Listing under `file.storage: 'gridfs'` | GridFS files | unchanged, plus any files left in the other stores by a previous driver |
|
|
300
|
+
| Listing under `'s3'` / `'filesystem'` | **always empty** | the files that are actually there |
|
|
301
|
+
| Deleting an S3- or filesystem-backed file | **impossible** (`File not found`) | deletes the bytes AND the metadata document |
|
|
302
|
+
| Deleting a GridFS file | GridFS delete | unchanged |
|
|
303
|
+
|
|
304
|
+
Nothing to configure. Two operational notes:
|
|
305
|
+
|
|
306
|
+
- **S3 deletion needs `CoreS3Service` to be usable.** When `s3` is not configured, the Hub refuses an
|
|
307
|
+
S3-backed delete with `S3 storage is not available.` rather than deleting the metadata document and
|
|
308
|
+
orphaning the object in the bucket. A half-delete is unrecoverable in a way a refusal is not.
|
|
309
|
+
- **Reading never creates a collection.** A GridFS-only deployment does not grow an empty `s3-files`
|
|
310
|
+
/ `filesystem-files` from the Hub looking at them — the same rule
|
|
311
|
+
`ensureFilenameIndex()` follows on the write path.
|
|
312
|
+
|
|
313
|
+
`HubFilesData` gained a `stores` summary and a `store` field per file. If you override
|
|
314
|
+
`CoreHubDbService` or consume `files.json` directly, both are additive.
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## Compatibility Notes
|
|
319
|
+
|
|
320
|
+
| Pattern | Status | Notes |
|
|
321
|
+
|---------|--------|-------|
|
|
322
|
+
| Project that never calls `duplicateByName()` / `duplicateById()` | **Unchanged** | |
|
|
323
|
+
| `duplicateById(id)` with the framework's default `checkRights()` | **Unchanged** | The default returns `true`, so an omitted context behaves exactly as before |
|
|
324
|
+
| `duplicateByName(a, b)` reading `.id` / `.filename` off the result | **Compatible** | `.id` is a `string` now instead of an `ObjectId`; `String(...)` / `getStringIds(...)` are unaffected |
|
|
325
|
+
| `duplicateByName(a, b)` treating the result as a stream | **Action required** | §1 — it is a `CoreFileInfo` now |
|
|
326
|
+
| Overridden fail-closed `checkRights()` + duplication without a context | **Action required** | §1 — pass `{ currentUser }` or `{ force: true }` |
|
|
327
|
+
| `FileService.duplicate()` copied out of `src/server` before 11.34.0 | **Action required** | §1 — the copy still bypasses `checkRights()`; replace the body with the delegation |
|
|
328
|
+
| Test asserting `MongoRuntimeError` on delete-by-unknown-id | **Action required** | §3 — it is `NotFoundException` now |
|
|
329
|
+
| `findFileInfo()` sorted on a nested field, `file.storage` other than `gridfs` | **Rows changed (fixed)** | §4 |
|
|
330
|
+
| `findFileInfo()` filtered on `contentType` | **Rows changed (fixed)** | §5 |
|
|
331
|
+
| Hub Files panel under `file.storage: 's3'` / `'filesystem'` | **Rows changed (fixed)** | §6 — it showed nothing before |
|
|
332
|
+
| Project overriding `CoreFileService.deleteFile()` | **Review** | If you call `super.deleteFile()`, it may now throw where it previously returned |
|
|
333
|
+
| Project overriding `GridFSHelper` behaviour | **Review** | `findFiles()` rewrites one filter key; `writeFileFromStream()` gained source-error handling |
|
|
334
|
+
| Project overriding `CoreS3Service.putObject()` | **Review** | A body STREAM is now relayed through a `PassThrough` before it reaches the SDK (§2); Buffer and string bodies are untouched |
|
|
335
|
+
| Project overriding `CoreHubDbService.getFiles()` / `deleteFile()` | **Review** | Both are multi-store now; `HubFilesData` gained `stores` + per-file `store` (§6) |
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
339
|
+
## Troubleshooting
|
|
340
|
+
|
|
341
|
+
| Symptom | Cause | Fix |
|
|
342
|
+
|---------|-------|-----|
|
|
343
|
+
| `NotFoundException: File not found with filename …` from a duplicate that used to work | Fail-closed `checkRights()` now applies to duplication (§1) | Pass `{ currentUser }` or `{ force: true }` |
|
|
344
|
+
| `Property 'pipe' does not exist on type 'CoreFileInfo'` | §1 — the return type changed | Use `copy.id` / `copy.filename`, or `getFileStream(copy.id)` if you really need bytes |
|
|
345
|
+
| A delete that returned 500 now returns 404 | §3 — that is the fix | Treat 404 as the idempotent-delete case |
|
|
346
|
+
| A copied file is suddenly invisible to its owner | The copy records no owner unless you say so (§1) | `duplicateById(id, { currentUser, metadata: { ownerId: currentUser.id } })` |
|
|
347
|
+
| Hub Files panel says `S3 storage is not available.` on delete | §6 — the file's bytes are in S3, but `s3` is not configured in this process | Configure `s3` (and install `@aws-sdk/client-s3`), or delete through the file API |
|
|
348
|
+
|
|
349
|
+
---
|
|
350
|
+
|
|
351
|
+
## Verification
|
|
352
|
+
|
|
353
|
+
```bash
|
|
354
|
+
pnpm run build
|
|
355
|
+
pnpm test
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
Framework suites covering this release:
|
|
359
|
+
|
|
360
|
+
| Suite | Covers |
|
|
361
|
+
|-------|--------|
|
|
362
|
+
| `tests/file-duplicate.e2e-spec.ts` | Duplication under all three drivers: default context, owner context, stranger, missing context, content type, copy metadata (§1) |
|
|
363
|
+
| `tests/file-upload-stream-error.e2e-spec.ts` | An aborted upload rejects instead of crashing, on GridFS and on the S3 streaming path (§2) |
|
|
364
|
+
| `tests/file-missing-answers.e2e-spec.ts` | By-id vs by-name answers for a file that is not there (§3) |
|
|
365
|
+
| `tests/file-find-info.e2e-spec.ts` | Merged multi-store ordering, dotted sort paths, paging, `contentType` filtering, and that unused metadata collections stay absent (§4, §5) |
|
|
366
|
+
| `tests/tus-finalization.e2e-spec.ts` | A finished tus upload lands in the active driver, takes the S3→S3 copy path, and clears the staging bucket |
|
|
367
|
+
| `tests/unit/core-hub-db.service.spec.ts` | The Hub Files panel across all three metadata stores, and its delete dispatch (§6) |
|
|
368
|
+
|
|
369
|
+
Every defect above is additionally backed by a **registered mutation** in
|
|
370
|
+
`tests/regression-mutations.json`: `pnpm run check:mutations` restores each bug on purpose and
|
|
371
|
+
requires the suites that claim to catch it to go red. A regression test that was never seen failing
|
|
372
|
+
is a claim, not a check — this release is the one that stopped taking those on trust.
|
|
373
|
+
|
|
374
|
+
---
|
|
375
|
+
|
|
376
|
+
## Module Documentation
|
|
377
|
+
|
|
378
|
+
### File
|
|
379
|
+
|
|
380
|
+
- [README](../src/core/modules/file/README.md) — § Access control, § Storage drivers
|
|
381
|
+
- [`CoreFileService`](../src/core/modules/file/core-file.service.ts)
|
|
382
|
+
- Reference implementation: `src/server/modules/file/`
|
|
383
|
+
|
|
384
|
+
### Hub
|
|
385
|
+
|
|
386
|
+
- [README](../src/core/modules/hub/README.md) — § Files panel
|
|
387
|
+
- [`CoreHubDbService`](../src/core/modules/hub/services/core-hub-db.service.ts)
|
|
388
|
+
|
|
389
|
+
---
|
|
390
|
+
|
|
391
|
+
## References
|
|
392
|
+
|
|
393
|
+
- [11.32.x → 11.33.x](./11.32.x-to-11.33.x.md) — the release that introduced the three storage drivers and closed the file endpoints
|
|
394
|
+
- [11.32.3 → 11.32.4](./11.32.3-to-11.32.4.md) — the earlier round of GridFS upload/download hardening
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.34.0",
|
|
4
4
|
"description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node",
|
|
@@ -24,8 +24,10 @@
|
|
|
24
24
|
"c": "pnpm run check",
|
|
25
25
|
"cf": "pnpm run check:fix",
|
|
26
26
|
"check": "node scripts/check.mjs",
|
|
27
|
+
"check:consumer": "node scripts/check-consumer.mjs",
|
|
27
28
|
"check:fix": "pnpm install && pnpm run spectaql:sync && pnpm audit --fix && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
|
|
28
29
|
"check:manifest": "node scripts/check-package-manifest.mjs",
|
|
30
|
+
"check:mutations": "node scripts/check-mutations.mjs",
|
|
29
31
|
"check:naf": "pnpm install && pnpm run spectaql:sync && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
|
|
30
32
|
"check:raw": "pnpm install --frozen-lockfile && pnpm run spectaql:sync && pnpm audit && pnpm run format:check && pnpm run lint && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
|
|
31
33
|
"check:swc-tdz": "nest build -b swc -p tsconfig.swc-tdz.json && node scripts/check-swc-tdz.mjs",
|
|
@@ -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
|
-
|
|
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
|
-
|
|
148
|
+
succeed(GridFSHelper.normalizeFileInfo(files[0]));
|
|
105
149
|
} else {
|
|
106
|
-
|
|
150
|
+
fail(new Error('File uploaded but metadata not found'));
|
|
107
151
|
}
|
|
108
152
|
})
|
|
109
|
-
.catch(
|
|
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
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
/**
|