@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.
Files changed (41) hide show
  1. package/.claude/rules/testing.md +181 -3
  2. package/FRAMEWORK-API.md +1 -1
  3. package/dist/core/common/helpers/gridfs.helper.d.ts +1 -0
  4. package/dist/core/common/helpers/gridfs.helper.js +43 -5
  5. package/dist/core/common/helpers/gridfs.helper.js.map +1 -1
  6. package/dist/core/common/services/core-s3.service.d.ts +4 -0
  7. package/dist/core/common/services/core-s3.service.js +28 -1
  8. package/dist/core/common/services/core-s3.service.js.map +1 -1
  9. package/dist/core/modules/file/core-file.service.d.ts +4 -2
  10. package/dist/core/modules/file/core-file.service.js +46 -47
  11. package/dist/core/modules/file/core-file.service.js.map +1 -1
  12. package/dist/core/modules/hub/helpers/hub-client-js.helper.js +12 -1
  13. package/dist/core/modules/hub/helpers/hub-client-js.helper.js.map +1 -1
  14. package/dist/core/modules/hub/hub-action-messages.d.ts +1 -0
  15. package/dist/core/modules/hub/hub-action-messages.js +1 -0
  16. package/dist/core/modules/hub/hub-action-messages.js.map +1 -1
  17. package/dist/core/modules/hub/interfaces/hub-panels.interface.d.ts +9 -0
  18. package/dist/core/modules/hub/services/core-hub-actions.service.d.ts +2 -0
  19. package/dist/core/modules/hub/services/core-hub-actions.service.js.map +1 -1
  20. package/dist/core/modules/hub/services/core-hub-db.service.d.ts +10 -2
  21. package/dist/core/modules/hub/services/core-hub-db.service.js +85 -29
  22. package/dist/core/modules/hub/services/core-hub-db.service.js.map +1 -1
  23. package/dist/server/modules/file/file.service.d.ts +2 -1
  24. package/dist/server/modules/file/file.service.js +2 -2
  25. package/dist/server/modules/file/file.service.js.map +1 -1
  26. package/dist/tsconfig.build.tsbuildinfo +1 -1
  27. package/migration-guides/11.23.x-to-11.24.0.md +1 -1
  28. package/migration-guides/11.32.x-to-11.33.x.md +2 -0
  29. package/migration-guides/11.33.x-to-11.34.x.md +394 -0
  30. package/package.json +3 -1
  31. package/src/core/common/helpers/gridfs.helper.ts +92 -7
  32. package/src/core/common/services/core-s3.service.ts +71 -9
  33. package/src/core/modules/file/README.md +42 -6
  34. package/src/core/modules/file/core-file.service.ts +131 -59
  35. package/src/core/modules/hub/README.md +35 -9
  36. package/src/core/modules/hub/helpers/hub-client-js.helper.ts +12 -1
  37. package/src/core/modules/hub/hub-action-messages.ts +9 -1
  38. package/src/core/modules/hub/interfaces/hub-panels.interface.ts +31 -1
  39. package/src/core/modules/hub/services/core-hub-actions.service.ts +10 -2
  40. package/src/core/modules/hub/services/core-hub-db.service.ts +155 -31
  41. package/src/server/modules/file/file.service.ts +20 -3
@@ -3,11 +3,26 @@ import { InjectConnection } from '@nestjs/mongoose';
3
3
  import * as mongo from 'mongodb';
4
4
  import { Connection, Types } from 'mongoose';
5
5
 
6
+ import { ConfigService } from '../../../common/services/config.service';
7
+ import { CoreS3Service } from '../../../common/services/core-s3.service';
6
8
  import { ModelRegistry } from '../../../common/services/model-registry.service';
9
+ import {
10
+ DEFAULT_FILESYSTEM_DIR,
11
+ FILESYSTEM_FILES_COLLECTION,
12
+ FilesystemFileHelper,
13
+ } from '../../file/filesystem-file.helper';
14
+ import { S3_FILES_COLLECTION, S3FileHelper } from '../../file/s3-file.helper';
7
15
  import { HubActionMessage } from '../hub-action-messages';
8
16
  import { HUB_CONFIG } from '../hub.constants';
9
17
  import { buildErDiagram, HubModelDescriptor, HubModelField } from '../helpers/hub-mermaid.helper';
10
- import { HubDbData, HubFilesData, HubModelsData, HubUnavailable } from '../interfaces/hub-panels.interface';
18
+ import {
19
+ HubDbData,
20
+ HubFilesData,
21
+ HubFileStore,
22
+ HubFileStoreSummary,
23
+ HubModelsData,
24
+ HubUnavailable,
25
+ } from '../interfaces/hub-panels.interface';
11
26
  import { ResolvedHubConfig } from '../interfaces/hub-config.interface';
12
27
 
13
28
  /**
@@ -23,6 +38,10 @@ export class CoreHubDbService {
23
38
  constructor(
24
39
  @Inject(HUB_CONFIG) protected readonly config: ResolvedHubConfig,
25
40
  @Optional() @InjectConnection() protected readonly connection?: Connection,
41
+ // Provided and exported globally by `CoreModule`, but INERT without an `s3` config — so it is
42
+ // optional here for the same reason it is everywhere else: a project that uses no S3 must not
43
+ // be forced to install `@aws-sdk/client-s3` to open the Hub.
44
+ @Optional() protected readonly s3Service?: CoreS3Service,
26
45
  ) {}
27
46
 
28
47
  /** Database + per-collection statistics. */
@@ -85,10 +104,21 @@ export class CoreHubDbService {
85
104
  }
86
105
 
87
106
  /**
88
- * Delete a GridFS file by id. When `expectedFilename` is given it must match the stored filename
89
- * (the type-to-confirm keyword). Returns the filename; throws when not found or the name mismatches.
107
+ * Delete a file by id, from WHICHEVER store actually holds it.
108
+ *
109
+ * When `expectedFilename` is given it must match the stored filename (the type-to-confirm
110
+ * keyword). Returns the filename and the store it came from; throws when not found, when the name
111
+ * mismatches, or when the owning store cannot be reached.
112
+ *
113
+ * This used to delete from GridFS only, which meant an S3- or filesystem-backed file could not be
114
+ * removed from the Hub at all — the lookup answered `File not found.` for a file plainly sitting
115
+ * in the bucket.
90
116
  */
91
- async deleteFile(id: string, expectedFilename?: string, bucket = 'fs'): Promise<{ filename: string; id: string }> {
117
+ async deleteFile(
118
+ id: string,
119
+ expectedFilename?: string,
120
+ bucket = 'fs',
121
+ ): Promise<{ filename: string; id: string; store: HubFileStore }> {
92
122
  const db = this.connection?.db;
93
123
  if (!db) {
94
124
  throw new Error(HubActionMessage.mongoUnavailable);
@@ -97,46 +127,140 @@ export class CoreHubDbService {
97
127
  throw new Error(HubActionMessage.invalidFileId);
98
128
  }
99
129
  const objectId = new Types.ObjectId(id);
100
- const [doc] = await db.collection(`${bucket}.files`).find({ _id: objectId }, { limit: 1 }).toArray();
101
- if (!doc) {
130
+
131
+ // Same probe order as `CoreFileService.deleteFile()`: S3, then filesystem, then GridFS as the
132
+ // fallthrough. Keeping the two in step matters — a Hub that resolved a file differently from the
133
+ // service would delete a different file than the one it displayed.
134
+ let store: HubFileStore | undefined;
135
+ let doc: any;
136
+ for (const candidate of this.fileStores(bucket)) {
137
+ const [found] = await db.collection(candidate.collection).find({ _id: objectId }, { limit: 1 }).toArray();
138
+ if (found) {
139
+ doc = found;
140
+ store = candidate.store;
141
+ break;
142
+ }
143
+ }
144
+ if (!doc || !store) {
102
145
  throw new Error(HubActionMessage.fileNotFound);
103
146
  }
104
147
  if (expectedFilename !== undefined && expectedFilename !== doc.filename) {
105
148
  throw new Error(HubActionMessage.confirmationFilenameMismatch);
106
149
  }
107
- const gridFs = new mongo.GridFSBucket(db as unknown as mongo.Db, { bucketName: bucket });
108
- await gridFs.delete(objectId as unknown as mongo.ObjectId);
109
- return { filename: doc.filename, id };
150
+
151
+ switch (store) {
152
+ case 'filesystem': {
153
+ await FilesystemFileHelper.deleteFile(this.filesystemDir, db.collection(FILESYSTEM_FILES_COLLECTION), objectId);
154
+ break;
155
+ }
156
+ case 's3': {
157
+ // Refuse rather than half-delete — see HubActionMessage.s3Unavailable.
158
+ if (!this.s3Service?.enabled) {
159
+ throw new Error(HubActionMessage.s3Unavailable);
160
+ }
161
+ await S3FileHelper.deleteFile(this.s3Service, db.collection(S3_FILES_COLLECTION), objectId);
162
+ break;
163
+ }
164
+ default: {
165
+ const gridFs = new mongo.GridFSBucket(db as unknown as mongo.Db, { bucketName: bucket });
166
+ await gridFs.delete(objectId as unknown as mongo.ObjectId);
167
+ }
168
+ }
169
+
170
+ return { filename: doc.filename, id, store };
110
171
  }
111
172
 
112
- /** GridFS file inventory for the given bucket (default `fs`). */
173
+ /**
174
+ * File inventory across ALL THREE metadata stores.
175
+ *
176
+ * Metadata always lives in MongoDB whichever store holds the bytes (`fs.files` / `s3-files` /
177
+ * `filesystem-files`), so one connection can see everything — which is exactly what
178
+ * `CoreFileService.findFileInfo()` does, and what this panel now mirrors.
179
+ *
180
+ * It used to read `fs.files` alone. Under `file.storage: 's3'` or `'filesystem'` that made the
181
+ * panel report **0 files** — not "this panel does not cover your driver", but a confident wrong
182
+ * answer, in the one tool an operator opens BECAUSE they are unsure. Someone checking whether an
183
+ * upload landed would have concluded it had not.
184
+ *
185
+ * Paging over a merge is the trap here: taking `skip`/`limit` from each store and concatenating
186
+ * returns the wrong ROWS, not merely the wrong order (the same defect `sortMergedFileInfo()` was
187
+ * fixed for). So each store yields its newest `skip + limit`, the union is re-sorted, and only
188
+ * then is the window cut — which is correct because the global newest `skip + limit` rows are
189
+ * necessarily contained in the per-store newest `skip + limit`.
190
+ *
191
+ * READS NEVER CREATE A COLLECTION: `find()` and `countDocuments()` on an absent collection answer
192
+ * empty. A GridFS-only deployment therefore does not grow an empty `s3-files` from the Hub merely
193
+ * looking at it — the same rule `ensureFilenameIndex()` follows on the write path.
194
+ */
113
195
  async getFiles(bucket = 'fs', skip = 0, limit = 100): Promise<HubFilesData | HubUnavailable> {
114
196
  const db = this.connection?.db;
115
197
  if (!db) {
116
198
  return { available: false, hint: 'No MongoDB connection is available.' };
117
199
  }
118
- const filesCollection = `${bucket}.files`;
119
- try {
120
- const cursor = db
121
- .collection(filesCollection)
122
- .find({}, { limit: Math.min(limit, 500), skip, sort: { uploadDate: -1 } });
123
- const docs = await cursor.toArray();
124
- const total = await db.collection(filesCollection).countDocuments();
125
- return {
126
- bucket,
127
- files: docs.map((doc) => ({
128
- contentType: doc.contentType ?? doc.metadata?.contentType,
129
- filename: doc.filename,
130
- id: String(doc._id),
131
- length: doc.length ?? 0,
132
- uploadDate: doc.uploadDate ? new Date(doc.uploadDate).toISOString() : undefined,
133
- })),
134
- total,
135
- };
136
- } catch (error) {
137
- this.logger.warn(`Failed to list GridFS files: ${error instanceof Error ? error.message : String(error)}`);
138
- return { available: false, hint: `No GridFS bucket "${bucket}" or it is empty.` };
200
+
201
+ const safeSkip = Math.max(0, skip);
202
+ const safeLimit = Math.min(Math.max(1, limit), 500);
203
+ // Per-store window: enough to guarantee the merged page is complete, capped so a huge `skip`
204
+ // cannot turn one panel poll into an unbounded read.
205
+ const window = Math.min(safeSkip + safeLimit, 1000);
206
+
207
+ const stores: HubFileStoreSummary[] = [];
208
+ const rows: HubFilesData['files'] = [];
209
+
210
+ for (const source of this.fileStores(bucket)) {
211
+ try {
212
+ const docs = await db
213
+ .collection(source.collection)
214
+ .find({}, { limit: window, sort: { uploadDate: -1 } })
215
+ .toArray();
216
+ const count = await db.collection(source.collection).countDocuments();
217
+ stores.push({ collection: source.collection, count, store: source.store });
218
+ for (const doc of docs) {
219
+ rows.push({
220
+ contentType: doc.contentType ?? doc.metadata?.contentType,
221
+ filename: doc.filename,
222
+ id: String(doc._id),
223
+ length: doc.length ?? 0,
224
+ store: source.store,
225
+ uploadDate: doc.uploadDate ? new Date(doc.uploadDate).toISOString() : undefined,
226
+ });
227
+ }
228
+ } catch (error) {
229
+ // One unreadable store must not blank the panel: the other two still hold real answers, and
230
+ // an operator is better served by "two stores listed, this one errored" than by nothing.
231
+ const message = error instanceof Error ? error.message : String(error);
232
+ this.logger.warn(`Failed to list files in ${source.collection}: ${message}`);
233
+ stores.push({ collection: source.collection, count: 0, error: message, store: source.store });
234
+ }
139
235
  }
236
+
237
+ rows.sort((a, b) => (b.uploadDate ?? '').localeCompare(a.uploadDate ?? ''));
238
+
239
+ return {
240
+ bucket,
241
+ files: rows.slice(safeSkip, safeSkip + safeLimit),
242
+ stores,
243
+ total: stores.reduce((sum, entry) => sum + entry.count, 0),
244
+ };
245
+ }
246
+
247
+ /** Directory used by the `'filesystem'` driver — resolved exactly as `CoreFileService` does. */
248
+ protected get filesystemDir(): string {
249
+ return ConfigService.configFastButReadOnly?.file?.storageDir || DEFAULT_FILESYSTEM_DIR;
250
+ }
251
+
252
+ /**
253
+ * The three metadata stores, in the order both this service and `CoreFileService` probe them.
254
+ *
255
+ * GridFS is last because it is the fallthrough: its collection name depends on the bucket, and a
256
+ * document there carries no `storage` marker to identify it by.
257
+ */
258
+ protected fileStores(bucket: string): { collection: string; store: HubFileStore }[] {
259
+ return [
260
+ { collection: S3_FILES_COLLECTION, store: 's3' },
261
+ { collection: FILESYSTEM_FILES_COLLECTION, store: 'filesystem' },
262
+ { collection: `${bucket}.files`, store: 'gridfs' },
263
+ ];
140
264
  }
141
265
 
142
266
  /** Model inventory + a Mermaid ER diagram derived from the registered Mongoose schemas. */
@@ -5,6 +5,7 @@ import { Connection } from 'mongoose';
5
5
  import { RoleEnum } from '../../../core/common/enums/role.enum';
6
6
  import { ConfigService } from '../../../core/common/services/config.service';
7
7
  import { CoreS3Service } from '../../../core/common/services/core-s3.service';
8
+ import { CoreFileInfo } from '../../../core/modules/file/core-file-info.model';
8
9
  import { CoreFileService, FileInputCheckType } from '../../../core/modules/file/core-file.service';
9
10
  import { FileServiceOptions } from '../../../core/modules/file/interfaces/file-service-options.interface';
10
11
 
@@ -22,10 +23,26 @@ export class FileService extends CoreFileService {
22
23
  }
23
24
 
24
25
  /**
25
- * Duplicate file by name
26
+ * Duplicate file by name.
27
+ *
28
+ * Delegates instead of reaching into `this.files` directly. The direct GridFS
29
+ * pipe this used to be was wrong in four separate ways, and every one of them
30
+ * is the kind of thing a consuming project copies out of here:
31
+ *
32
+ * - it only ever worked on the GridFS driver — under `file.storage: 's3'` or
33
+ * `'filesystem'` the source is simply not in the bucket;
34
+ * - it bypassed `checkRights()` entirely, so the owner rule below did not apply
35
+ * to a duplicate at all;
36
+ * - it returned the write stream without awaiting it, so the caller was told the
37
+ * copy existed while it was still being written;
38
+ * - neither stream carried an error handler, and an unhandled stream `'error'`
39
+ * takes the whole process down.
40
+ *
41
+ * `duplicateByName()` answers all four. Forward the caller's context so the
42
+ * duplicate is COVERED by the ownership rule rather than exempt from it.
26
43
  */
27
- async duplicate(fileName: string, newName: string): Promise<any> {
28
- return this.files.openDownloadStreamByName(fileName).pipe(this.files.openUploadStream(newName));
44
+ async duplicate(fileName: string, newName: string, serviceOptions?: FileServiceOptions): Promise<CoreFileInfo> {
45
+ return this.duplicateByName(fileName, newName, serviceOptions);
29
46
  }
30
47
 
31
48
  /**