@lenne.tech/nest-server 11.32.2 → 11.32.4
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/configurable-features.md +1 -1
- package/FRAMEWORK-API.md +3 -1
- package/bin/migrate.js +13 -3
- package/dist/core/common/helpers/file.helper.d.ts +14 -2
- package/dist/core/common/helpers/file.helper.js +48 -9
- package/dist/core/common/helpers/file.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +2 -0
- package/dist/core/modules/ai/inputs/core-ai-connection.input.js +2 -0
- package/dist/core/modules/ai/inputs/core-ai-connection.input.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-connection.service.d.ts +1 -0
- package/dist/core/modules/ai/services/core-ai-connection.service.js +68 -0
- package/dist/core/modules/ai/services/core-ai-connection.service.js.map +1 -1
- package/dist/core/modules/file/core-file.controller.d.ts +4 -1
- package/dist/core/modules/file/core-file.controller.js +39 -6
- package/dist/core/modules/file/core-file.controller.js.map +1 -1
- package/dist/core/modules/migrate/cli/migrate-cli.d.ts +3 -1
- package/dist/core/modules/migrate/cli/migrate-cli.js +29 -4
- package/dist/core/modules/migrate/cli/migrate-cli.js.map +1 -1
- package/dist/core/modules/migrate/helpers/migration.helper.d.ts +1 -0
- package/dist/core/modules/migrate/helpers/migration.helper.js +51 -4
- package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/security-overrides.md +9 -2
- package/migration-guides/11.32.2-to-11.32.3.md +129 -0
- package/migration-guides/11.32.3-to-11.32.4.md +323 -0
- package/package.json +1 -1
- package/src/core/common/helpers/file.helper.spec.ts +145 -0
- package/src/core/common/helpers/file.helper.ts +148 -10
- package/src/core/common/interfaces/server-options.interface.ts +23 -0
- package/src/core/modules/ai/README.md +6 -0
- package/src/core/modules/ai/inputs/core-ai-connection.input.ts +2 -0
- package/src/core/modules/ai/interfaces/ai-tool.interface.ts +18 -3
- package/src/core/modules/ai/services/core-ai-connection.service.ts +135 -0
- package/src/core/modules/file/README.md +59 -0
- package/src/core/modules/file/core-file.controller.spec.ts +164 -0
- package/src/core/modules/file/core-file.controller.ts +100 -8
- package/src/core/modules/migrate/README.md +35 -0
- package/src/core/modules/migrate/cli/migrate-cli.ts +69 -6
- package/src/core/modules/migrate/helpers/migration.helper.spec.ts +85 -0
- package/src/core/modules/migrate/helpers/migration.helper.ts +131 -4
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { NotFoundException } from '@nestjs/common';
|
|
2
|
+
import type { Response } from 'express';
|
|
3
|
+
import { PassThrough, Readable } from 'stream';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import type { CoreFileService } from './core-file.service';
|
|
7
|
+
import { CoreFileController, pipeFileToResponse } from './core-file.controller';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Build a response stub that records what the handler did to it.
|
|
11
|
+
*
|
|
12
|
+
* A real `Response` is not needed here — what is under test is the decision
|
|
13
|
+
* (status vs. destroy), not Express. Writing it out as a stub also makes the
|
|
14
|
+
* "headers already sent" case reachable, which is awkward to force otherwise.
|
|
15
|
+
*
|
|
16
|
+
* What this stub CANNOT show is how Express itself treats headers: `res.json()`
|
|
17
|
+
* only defaults `Content-Type` when none is set, so a stub whose `json()` merely
|
|
18
|
+
* records a body can never reveal a stale one. That property is pinned at the
|
|
19
|
+
* HTTP level in `tests/file.e2e-spec.ts`; here we assert the removal list.
|
|
20
|
+
*/
|
|
21
|
+
function responseStub(headersSent = false) {
|
|
22
|
+
const sink = new PassThrough();
|
|
23
|
+
const calls = {
|
|
24
|
+
destroyed: false,
|
|
25
|
+
json: undefined as unknown,
|
|
26
|
+
removed: [] as string[],
|
|
27
|
+
set: {} as Record<string, string>,
|
|
28
|
+
status: 0,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const res = Object.assign(sink, {
|
|
32
|
+
destroy: () => {
|
|
33
|
+
calls.destroyed = true;
|
|
34
|
+
return res;
|
|
35
|
+
},
|
|
36
|
+
headersSent,
|
|
37
|
+
json: (body: unknown) => {
|
|
38
|
+
calls.json = body;
|
|
39
|
+
return res;
|
|
40
|
+
},
|
|
41
|
+
removeHeader: (name: string) => {
|
|
42
|
+
calls.removed.push(name);
|
|
43
|
+
},
|
|
44
|
+
setHeader: (name: string, value: string) => {
|
|
45
|
+
calls.set[name] = value;
|
|
46
|
+
return res;
|
|
47
|
+
},
|
|
48
|
+
status: (code: number) => {
|
|
49
|
+
calls.status = code;
|
|
50
|
+
return res;
|
|
51
|
+
},
|
|
52
|
+
}) as unknown as Response;
|
|
53
|
+
|
|
54
|
+
return { calls, res };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('pipeFileToResponse', () => {
|
|
58
|
+
it('turns a read error into 404 while nothing has been written yet', async () => {
|
|
59
|
+
// A GridFS record and its bytes are two separate writes, so the record can
|
|
60
|
+
// outlive the chunks. GridFS reports that on the stream, asynchronously.
|
|
61
|
+
// A bare `stream.pipe(res)` installs no error handler, so the error goes
|
|
62
|
+
// unhandled, Node destroys the socket mid-response, and any reverse proxy
|
|
63
|
+
// in front reports 502 Bad Gateway — "the server is down", which is the one
|
|
64
|
+
// diagnosis that is wrong while every other route answers normally.
|
|
65
|
+
const stream = new Readable({ read() {} });
|
|
66
|
+
const { calls, res } = responseStub(false);
|
|
67
|
+
|
|
68
|
+
pipeFileToResponse(stream, res);
|
|
69
|
+
stream.emit('error', new Error('FileNotFound: file 0123456789ab was not found'));
|
|
70
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
71
|
+
|
|
72
|
+
expect(calls.status).toBe(404);
|
|
73
|
+
expect(calls.json).toMatchObject({ statusCode: 404 });
|
|
74
|
+
expect(calls.destroyed).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('drops every header that describes the file, not just the download one', async () => {
|
|
78
|
+
// Each of these would otherwise survive onto the error response:
|
|
79
|
+
// `Content-Disposition` offers a JSON error body as a file to save, a long
|
|
80
|
+
// `Cache-Control` lets the client cache the failure, `ETag` describes bytes
|
|
81
|
+
// that were never sent — and `Content-Type` makes an ofetch/`$fetch` client
|
|
82
|
+
// parse the JSON as an image and lose the message entirely.
|
|
83
|
+
const stream = new Readable({ read() {} });
|
|
84
|
+
const { calls, res } = responseStub(false);
|
|
85
|
+
|
|
86
|
+
pipeFileToResponse(stream, res);
|
|
87
|
+
stream.emit('error', new Error('chunks missing'));
|
|
88
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
89
|
+
|
|
90
|
+
expect(calls.removed).toEqual(
|
|
91
|
+
expect.arrayContaining(['Cache-Control', 'Content-Disposition', 'Content-Type', 'ETag']),
|
|
92
|
+
);
|
|
93
|
+
expect(calls.set['X-Content-Type-Options']).toBe('nosniff');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('closes the connection once bytes are already on the wire', async () => {
|
|
97
|
+
// Past the first byte there is no status left to send. Dropping the
|
|
98
|
+
// connection is the only remaining signal — and it is the correct one: it
|
|
99
|
+
// is what a truncated transfer looks like, so the client does not cache a
|
|
100
|
+
// half file as if it were complete.
|
|
101
|
+
const stream = new Readable({ read() {} });
|
|
102
|
+
const { calls, res } = responseStub(true);
|
|
103
|
+
|
|
104
|
+
pipeFileToResponse(stream, res);
|
|
105
|
+
stream.emit('error', new Error('connection lost mid-stream'));
|
|
106
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
107
|
+
|
|
108
|
+
expect(calls.destroyed).toBe(true);
|
|
109
|
+
expect(calls.status).toBe(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('answers a refused download exactly like an unknown id', async () => {
|
|
113
|
+
// `getFileStream()` returns null when the service's own `checkRights()`
|
|
114
|
+
// refuses — a branch only a CONSUMER project can reach, since the framework's
|
|
115
|
+
// base implementation always returns true. That is precisely why the
|
|
116
|
+
// framework has to pin it: consumers cannot, and if the two answers ever
|
|
117
|
+
// diverge (403 here, 404 there, or different messages) the endpoint becomes
|
|
118
|
+
// an existence oracle for files the caller may not read.
|
|
119
|
+
// Invoked through the prototype rather than a subclass: `CoreFileController`
|
|
120
|
+
// has a protected constructor (it is abstract by design), and a subclass added
|
|
121
|
+
// only to widen that visibility is exactly what `no-useless-constructor`
|
|
122
|
+
// strips — leaving code that no longer compiles.
|
|
123
|
+
const download = (service: Partial<CoreFileService>, res: Response) =>
|
|
124
|
+
(CoreFileController.prototype.getFileById as (this: unknown, id: string, res: Response) => Promise<unknown>).call(
|
|
125
|
+
{ fileService: service },
|
|
126
|
+
'abc',
|
|
127
|
+
res,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const file = { contentType: 'image/png', filename: 'secret.png', id: 'abc' };
|
|
131
|
+
const { res } = responseStub(false);
|
|
132
|
+
|
|
133
|
+
const refusedErr = await download(
|
|
134
|
+
{ getFileInfo: async () => file, getFileStream: async () => null } as unknown as Partial<CoreFileService>,
|
|
135
|
+
res,
|
|
136
|
+
).catch((e: unknown) => e);
|
|
137
|
+
const unknownErr = await download(
|
|
138
|
+
{ getFileInfo: async () => null, getFileStream: async () => null } as unknown as Partial<CoreFileService>,
|
|
139
|
+
res,
|
|
140
|
+
).catch((e: unknown) => e);
|
|
141
|
+
|
|
142
|
+
expect(refusedErr).toBeInstanceOf(NotFoundException);
|
|
143
|
+
expect(unknownErr).toBeInstanceOf(NotFoundException);
|
|
144
|
+
// Byte-identical answers: same status, same message.
|
|
145
|
+
expect((refusedErr as NotFoundException).getStatus()).toBe((unknownErr as NotFoundException).getStatus());
|
|
146
|
+
expect((refusedErr as NotFoundException).getResponse()).toEqual((unknownErr as NotFoundException).getResponse());
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('destroys the source stream when the client goes away', async () => {
|
|
150
|
+
// `pipe()` only ever unpipes the DESTINATION; it never destroys the source.
|
|
151
|
+
// On a public download route an aborted request would therefore leak the
|
|
152
|
+
// GridFS read stream and its server-side cursor until the cursor timeout.
|
|
153
|
+
const stream = new Readable({ read() {} });
|
|
154
|
+
const { res } = responseStub(false);
|
|
155
|
+
|
|
156
|
+
pipeFileToResponse(stream, res);
|
|
157
|
+
expect(stream.destroyed).toBe(false);
|
|
158
|
+
|
|
159
|
+
res.emit('close');
|
|
160
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
161
|
+
|
|
162
|
+
expect(stream.destroyed).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -1,10 +1,82 @@
|
|
|
1
|
-
import { BadRequestException, Controller, Get, NotFoundException, Param, Res } from '@nestjs/common';
|
|
2
|
-
import { Response } from 'express';
|
|
1
|
+
import { BadRequestException, Controller, Get, Logger, NotFoundException, Param, Res } from '@nestjs/common';
|
|
2
|
+
import type { Response } from 'express';
|
|
3
|
+
import type { Readable } from 'stream';
|
|
3
4
|
|
|
4
5
|
import { Roles } from '../../common/decorators/roles.decorator';
|
|
5
6
|
import { RoleEnum } from '../../common/enums/role.enum';
|
|
7
|
+
import { ErrorCode } from '../error-code/error-codes';
|
|
6
8
|
import { CoreFileService } from './core-file.service';
|
|
7
9
|
|
|
10
|
+
const fileStreamLogger = new Logger('CoreFileController');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Headers that describe the FILE and must not survive onto an error response.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately not the whole set: CORS headers are already on the response at
|
|
16
|
+
* this point, and removing those would turn a readable 404 into an opaque CORS
|
|
17
|
+
* failure in the browser — hiding the very answer this handler exists to give.
|
|
18
|
+
*
|
|
19
|
+
* `Content-Type` is the one most easily missed. Express only defaults it in
|
|
20
|
+
* `res.json()` when nothing is set yet (`if (!this.get('Content-Type'))`), so the
|
|
21
|
+
* file's own type would otherwise label a JSON body as `image/png` — and an
|
|
22
|
+
* ofetch/`$fetch` client picks its parser from that header and hands the caller a
|
|
23
|
+
* Blob instead of the error message.
|
|
24
|
+
*/
|
|
25
|
+
const FILE_DELIVERY_HEADERS = ['Cache-Control', 'Content-Disposition', 'Content-Type', 'ETag'];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Pipe a GridFS download to the response without letting a read error kill the socket.
|
|
29
|
+
*
|
|
30
|
+
* A GridFS record and its chunks are two separate writes, so a file document can
|
|
31
|
+
* outlive its bytes — an interrupted upload, a restored backup, a manual cleanup.
|
|
32
|
+
* GridFS reports that asynchronously, on the stream, and `stream.pipe(res)` alone
|
|
33
|
+
* installs no error handler: the error goes unhandled, Node destroys the socket
|
|
34
|
+
* mid-response, and any reverse proxy in front turns that into **502 Bad Gateway**.
|
|
35
|
+
* That reads as "the server is down" — the one diagnosis that is wrong here, while
|
|
36
|
+
* every other route keeps answering normally.
|
|
37
|
+
*
|
|
38
|
+
* Nothing has been written when GridFS reports a missing file, so the status is
|
|
39
|
+
* still ours to set and the answer becomes an honest 404. Once bytes are on the
|
|
40
|
+
* wire there is no status left to send and closing the connection is all that
|
|
41
|
+
* remains — but at that point it genuinely is a truncated transfer, which is
|
|
42
|
+
* exactly what a dropped connection means to the client.
|
|
43
|
+
*
|
|
44
|
+
* Note on `pipe()` vs `stream.pipeline()`: pipeline would destroy BOTH streams on
|
|
45
|
+
* error, including the response — which is precisely the object still needed to
|
|
46
|
+
* send the 404. So the source is cleaned up explicitly on `res` close instead;
|
|
47
|
+
* `pipe()` only ever unpipes the destination and would otherwise leave the
|
|
48
|
+
* GridFS read stream and its server-side cursor open on every aborted download.
|
|
49
|
+
*/
|
|
50
|
+
export function pipeFileToResponse(stream: Readable, res: Response): Response {
|
|
51
|
+
res.on('close', () => {
|
|
52
|
+
if (!stream.destroyed) {
|
|
53
|
+
stream.destroy();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
stream.on('error', (err: Error) => {
|
|
58
|
+
// The answer to the CLIENT stays deliberately indistinguishable from an
|
|
59
|
+
// unknown id, but the server must not lose the event: a files document
|
|
60
|
+
// without its chunks is data corruption, and an error silently converted
|
|
61
|
+
// into a 404 is one nobody will ever notice.
|
|
62
|
+
fileStreamLogger.error(`GridFS download failed: ${err.message}`, err.stack);
|
|
63
|
+
|
|
64
|
+
if (res.headersSent) {
|
|
65
|
+
res.destroy();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
for (const header of FILE_DELIVERY_HEADERS) {
|
|
69
|
+
res.removeHeader(header);
|
|
70
|
+
}
|
|
71
|
+
// No global nosniff on this route, and the body is now correctly typed —
|
|
72
|
+
// set it anyway so a mislabelled response can never be sniffed into markup.
|
|
73
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
74
|
+
res.status(404).json({ error: 'Not Found', message: ErrorCode.FILE_NOT_FOUND, statusCode: 404 });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return stream.pipe(res);
|
|
78
|
+
}
|
|
79
|
+
|
|
8
80
|
/**
|
|
9
81
|
* File controller
|
|
10
82
|
*/
|
|
@@ -16,6 +88,18 @@ export abstract class CoreFileController {
|
|
|
16
88
|
*/
|
|
17
89
|
protected constructor(protected fileService: CoreFileService) {}
|
|
18
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Stream a file to the response.
|
|
93
|
+
*
|
|
94
|
+
* Delegates to the module-level {@link pipeFileToResponse} (which stays exported
|
|
95
|
+
* so it can be unit-tested without a Nest context). Override this to change the
|
|
96
|
+
* error status, the body shape or the logging for a project — the free function
|
|
97
|
+
* alone would not be reachable from a subclass.
|
|
98
|
+
*/
|
|
99
|
+
protected pipeFileToResponse(stream: Readable, res: Response): Response {
|
|
100
|
+
return pipeFileToResponse(stream, res);
|
|
101
|
+
}
|
|
102
|
+
|
|
19
103
|
/**
|
|
20
104
|
* Download file by ID
|
|
21
105
|
*
|
|
@@ -26,17 +110,22 @@ export abstract class CoreFileController {
|
|
|
26
110
|
@Roles(RoleEnum.S_EVERYONE)
|
|
27
111
|
async getFileById(@Param('id') id: string, @Res() res: Response) {
|
|
28
112
|
if (!id) {
|
|
29
|
-
throw new BadRequestException(
|
|
113
|
+
throw new BadRequestException(ErrorCode.REQUIRED_FIELD_MISSING);
|
|
30
114
|
}
|
|
31
115
|
|
|
32
116
|
const file = await this.fileService.getFileInfo(id);
|
|
33
117
|
if (!file) {
|
|
34
|
-
throw new NotFoundException(
|
|
118
|
+
throw new NotFoundException(ErrorCode.FILE_NOT_FOUND);
|
|
35
119
|
}
|
|
36
120
|
const filestream = await this.fileService.getFileStream(id);
|
|
121
|
+
// `getFileStream` answers null when the service's own rights check refuses.
|
|
122
|
+
// Same answer as an unknown id: never confirm that the file exists.
|
|
123
|
+
if (!filestream) {
|
|
124
|
+
throw new NotFoundException(ErrorCode.FILE_NOT_FOUND);
|
|
125
|
+
}
|
|
37
126
|
res.header('Content-Type', file.contentType || 'application/octet-stream');
|
|
38
127
|
res.header('Content-Disposition', `attachment; filename=${file.filename}`);
|
|
39
|
-
return
|
|
128
|
+
return this.pipeFileToResponse(filestream, res);
|
|
40
129
|
}
|
|
41
130
|
|
|
42
131
|
/**
|
|
@@ -49,16 +138,19 @@ export abstract class CoreFileController {
|
|
|
49
138
|
@Roles(RoleEnum.S_EVERYONE)
|
|
50
139
|
async getFile(@Param('filename') filename: string, @Res() res: Response) {
|
|
51
140
|
if (!filename) {
|
|
52
|
-
throw new BadRequestException(
|
|
141
|
+
throw new BadRequestException(ErrorCode.REQUIRED_FIELD_MISSING);
|
|
53
142
|
}
|
|
54
143
|
|
|
55
144
|
const file = await this.fileService.getFileInfoByName(filename);
|
|
56
145
|
if (!file) {
|
|
57
|
-
throw new NotFoundException(
|
|
146
|
+
throw new NotFoundException(ErrorCode.FILE_NOT_FOUND);
|
|
58
147
|
}
|
|
59
148
|
const filestream = await this.fileService.getFileStream(file.id);
|
|
149
|
+
if (!filestream) {
|
|
150
|
+
throw new NotFoundException(ErrorCode.FILE_NOT_FOUND);
|
|
151
|
+
}
|
|
60
152
|
res.header('Content-Type', file.contentType || 'application/octet-stream');
|
|
61
153
|
res.header('Content-Disposition', `attachment; filename=${file.filename}`);
|
|
62
|
-
return
|
|
154
|
+
return this.pipeFileToResponse(filestream, res);
|
|
63
155
|
}
|
|
64
156
|
}
|
|
@@ -387,6 +387,41 @@ const fileId = await uploadFileToGridFS('mongodb://localhost/mydb', '../assets/i
|
|
|
387
387
|
});
|
|
388
388
|
```
|
|
389
389
|
|
|
390
|
+
`relativePath` is resolved against the **helper module's** directory, not the migration file's — the
|
|
391
|
+
paths in this example and in the migration template are written accordingly.
|
|
392
|
+
|
|
393
|
+
The returned promise settles only after three guarantees hold:
|
|
394
|
+
|
|
395
|
+
| Guarantee | Behaviour |
|
|
396
|
+
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
397
|
+
| The bytes are stored | Chunk completeness is verified before resolving (see `assertGridFsFileComplete()` below). An incomplete upload **rejects** and the incomplete file is removed, rather than returning an id that points at nothing |
|
|
398
|
+
| An unreadable source fails fast | A missing or unreadable file **rejects** with the underlying `ENOENT`. It used to hang forever, because `pipe()` does not forward read-stream errors |
|
|
399
|
+
| The connection is released | The MongoClient it opens is always closed, on the success and the failure path alike. A leaked connection keeps the Node event loop alive and `migrate up` never exits |
|
|
400
|
+
|
|
401
|
+
Since these are hard failures, a seed migration whose asset is missing or only partially stored now
|
|
402
|
+
fails the migration — and with the default `MIGRATE_FAILURE_POLICY=abort` in `docker-entrypoint.sh`,
|
|
403
|
+
the container refuses to start rather than booting with broken data. That is intentional: the point
|
|
404
|
+
of running migrations before the server is to find exactly this.
|
|
405
|
+
|
|
406
|
+
### assertGridFsFileComplete()
|
|
407
|
+
|
|
408
|
+
Verifies that every chunk of an already-stored GridFS file is present. `uploadFileToGridFS()` calls
|
|
409
|
+
it for you; call it directly to check files written by something else (a restored dump, another
|
|
410
|
+
service, a manual upload):
|
|
411
|
+
|
|
412
|
+
```typescript
|
|
413
|
+
import { assertGridFsFileComplete, getDb } from '@lenne.tech/nest-server';
|
|
414
|
+
|
|
415
|
+
const db = await getDb('mongodb://localhost/mydb');
|
|
416
|
+
await assertGridFsFileComplete(db, 'images', fileId, 'logo.png'); // throws if incomplete
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Throws when the files document is missing entirely, or when fewer chunks are stored than its
|
|
420
|
+
`length` implies. It counts chunk documents rather than reading bytes back, so a chunk that was
|
|
421
|
+
written but truncated is **not** detected — the check targets the failure that actually occurs, a
|
|
422
|
+
connection lost mid-upload. Empty files are valid and pass: GridFS stores a zero-byte file with no
|
|
423
|
+
chunk documents at all.
|
|
424
|
+
|
|
390
425
|
### Migration Templates
|
|
391
426
|
|
|
392
427
|
Ready-to-use template for nest-server projects:
|
|
@@ -328,13 +328,76 @@ Environment Variables:
|
|
|
328
328
|
`);
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
331
|
+
/**
|
|
332
|
+
* Exit with `code`, but never before our own output has actually left the process.
|
|
333
|
+
*
|
|
334
|
+
* `process.exit()` does NOT drain stdout, and stdout is asynchronous whenever it
|
|
335
|
+
* is a pipe — which is exactly the Docker / CI / `| tee` case. Exiting straight
|
|
336
|
+
* after the completion log therefore discards the record of what this run did,
|
|
337
|
+
* and the line at risk is the last one ("All migrations completed successfully"),
|
|
338
|
+
* which is precisely what a CI step greps for. Measured: past the 64 KiB pipe
|
|
339
|
+
* buffer everything beyond it is lost.
|
|
340
|
+
*
|
|
341
|
+
* `process.exitCode` is assigned first so that a future `exitCode = 1` elsewhere
|
|
342
|
+
* is honoured rather than overwritten by a hardcoded 0. The timer is the safety
|
|
343
|
+
* net for the opposite failure — a consumer that never reads — and is `unref`'d
|
|
344
|
+
* so it cannot itself keep the process alive.
|
|
345
|
+
*/
|
|
346
|
+
const flushAndExit = async (code: number): Promise<void> => {
|
|
347
|
+
process.exitCode = code;
|
|
348
|
+
|
|
349
|
+
const guard = setTimeout(() => process.exit(code), 5000);
|
|
350
|
+
guard.unref();
|
|
351
|
+
|
|
352
|
+
await Promise.all(
|
|
353
|
+
[process.stdout, process.stderr].map(
|
|
354
|
+
(stream) =>
|
|
355
|
+
new Promise<void>((resolve) => {
|
|
356
|
+
if (!stream.writableLength) {
|
|
357
|
+
resolve();
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
stream.write('', () => resolve());
|
|
361
|
+
}),
|
|
362
|
+
),
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
clearTimeout(guard);
|
|
366
|
+
process.exit(code);
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Run the CLI and terminate the process when it is done.
|
|
371
|
+
*
|
|
372
|
+
* This is the entry point the `migrate` / `nest-migrate` bin uses. It exists
|
|
373
|
+
* separately from `main()` because the shell around it matters: migrations touch
|
|
374
|
+
* MongoDB, GridFS and — via the state store — a second connection, and any handle
|
|
375
|
+
* one of them leaves behind keeps Node alive forever. The CLI then prints
|
|
376
|
+
* "All migrations completed successfully" and simply never returns: a CI job
|
|
377
|
+
* blocks until its timeout, and a container entrypoint that runs migrations
|
|
378
|
+
* before `exec`ing the server never reaches the server at all.
|
|
379
|
+
*
|
|
380
|
+
* Note this must NOT be guarded by `require.main === module`: the shipped
|
|
381
|
+
* `bin/migrate.js` loads this module and calls in, so `require.main` is the shim,
|
|
382
|
+
* never this file. A guard here would make the exit unreachable on every path
|
|
383
|
+
* that actually ships.
|
|
384
|
+
*/
|
|
385
|
+
const runCli = async (): Promise<void> => {
|
|
386
|
+
try {
|
|
387
|
+
await main();
|
|
388
|
+
} catch (error) {
|
|
334
389
|
console.error('Fatal error:', error);
|
|
335
|
-
|
|
336
|
-
|
|
390
|
+
await flushAndExit(1);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
await flushAndExit(0);
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
// Run CLI if executed directly (`node migrate-cli.js`). The bin shim calls
|
|
397
|
+
// runCli() itself, so this only covers a direct invocation of the compiled file.
|
|
398
|
+
if (require.main === module) {
|
|
399
|
+
void runCli();
|
|
337
400
|
}
|
|
338
401
|
|
|
339
402
|
// parseArgs is exported for unit testing only (same pattern as resolveCliPath in bin/migrate.js)
|
|
340
|
-
export { main, parseArgs };
|
|
403
|
+
export { flushAndExit, main, parseArgs, runCli };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Db } from 'mongodb';
|
|
2
|
+
import { ObjectId } from 'mongodb';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { assertGridFsFileComplete } from './migration.helper';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Minimal stand-in for the two collections the check reads.
|
|
9
|
+
*
|
|
10
|
+
* A real Mongo instance is not needed to pin this contract — what matters is
|
|
11
|
+
* the decision the function makes given a files document and a chunk count.
|
|
12
|
+
* The stub records what it was asked for, so a hardcoded bucket name or a wrong
|
|
13
|
+
* filter key cannot slip past unnoticed; the real-MongoDB counterpart lives in
|
|
14
|
+
* `tests/migrate/upload-file-to-gridfs.e2e-spec.ts`.
|
|
15
|
+
*/
|
|
16
|
+
function dbStub(files: null | Record<string, unknown>, chunkCount: number) {
|
|
17
|
+
const asked: { filters: unknown[]; names: string[] } = { filters: [], names: [] };
|
|
18
|
+
|
|
19
|
+
const db = {
|
|
20
|
+
collection: (name: string) => {
|
|
21
|
+
asked.names.push(name);
|
|
22
|
+
if (name.endsWith('.files')) {
|
|
23
|
+
return {
|
|
24
|
+
findOne: async (filter: unknown) => {
|
|
25
|
+
asked.filters.push(filter);
|
|
26
|
+
return files;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
countDocuments: async (filter: unknown) => {
|
|
32
|
+
asked.filters.push(filter);
|
|
33
|
+
return chunkCount;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
} as unknown as Db;
|
|
38
|
+
|
|
39
|
+
return { asked, db };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('assertGridFsFileComplete', () => {
|
|
43
|
+
const id = new ObjectId();
|
|
44
|
+
|
|
45
|
+
it('passes when every chunk is stored', async () => {
|
|
46
|
+
// 700 KiB at the default 255 KiB chunk size = 3 chunks.
|
|
47
|
+
const { db } = dbStub({ _id: id, chunkSize: 261120, length: 716800 }, 3);
|
|
48
|
+
await expect(assertGridFsFileComplete(db, 'fs', id, 'layout.jpg')).resolves.toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('rejects when the record promises more bytes than exist', async () => {
|
|
52
|
+
// The nasty case: the files document looks perfectly healthy, so every
|
|
53
|
+
// listing and every metadata check says the file is fine — only the
|
|
54
|
+
// download fails. Reporting this at upload time is what keeps a broken
|
|
55
|
+
// asset from being persisted as if it were good.
|
|
56
|
+
const { db } = dbStub({ _id: id, chunkSize: 261120, length: 716800 }, 1);
|
|
57
|
+
await expect(assertGridFsFileComplete(db, 'fs', id, 'layout.jpg')).rejects.toThrow(/incomplete: 1 of 3 chunks/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('rejects when the file document is missing entirely', async () => {
|
|
61
|
+
const { db } = dbStub(null, 0);
|
|
62
|
+
await expect(assertGridFsFileComplete(db, 'fs', id, 'layout.jpg')).rejects.toThrow(/no file document/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('accepts an empty file, which GridFS stores with no chunks at all', async () => {
|
|
66
|
+
// GridFS does NOT write a placeholder chunk for a zero-byte file: the
|
|
67
|
+
// driver's `writeRemnant()` returns early on `pos === 0`. A `Math.max(1, …)`
|
|
68
|
+
// floor in the expectation would therefore reject every legitimately empty
|
|
69
|
+
// asset — and since the container entrypoint defaults to
|
|
70
|
+
// `MIGRATE_FAILURE_POLICY=abort`, that would keep the server from starting.
|
|
71
|
+
const { db } = dbStub({ _id: id, chunkSize: 261120, length: 0 }, 0);
|
|
72
|
+
await expect(assertGridFsFileComplete(db, 'fs', id, 'empty.bin')).resolves.toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('reads the bucket it was given, filtered by the file id', async () => {
|
|
76
|
+
// Guards the two things the assertions above cannot see: that the bucket
|
|
77
|
+
// name is not hardcoded, and that the chunk lookup uses `files_id` (a wrong
|
|
78
|
+
// key would count zero and turn every upload into a false failure).
|
|
79
|
+
const { asked, db } = dbStub({ _id: id, chunkSize: 261120, length: 261120 }, 1);
|
|
80
|
+
await assertGridFsFileComplete(db, 'images', id, 'logo.png');
|
|
81
|
+
|
|
82
|
+
expect(asked.names).toEqual(['images.files', 'images.chunks']);
|
|
83
|
+
expect(asked.filters).toEqual([{ _id: id }, { files_id: id }]);
|
|
84
|
+
});
|
|
85
|
+
});
|