@airoom/nextmin-node 2.0.1 → 2.0.2
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/dist/api/apiRouter.d.ts +8 -1
- package/dist/api/apiRouter.js +16 -1
- package/dist/api/router/mountBatchRoutes.d.ts +2 -0
- package/dist/api/router/mountBatchRoutes.js +90 -0
- package/dist/api/router/mountCrudRoutes.js +10 -2
- package/dist/api/router/setupFileRoutes.js +41 -0
- package/dist/api/router/utils.d.ts +2 -0
- package/dist/api/router/utils.js +17 -1
- package/dist/database/DatabaseAdapter.d.ts +1 -0
- package/dist/database/NMAdapter.d.ts +5 -0
- package/dist/database/NMAdapter.js +273 -9
- package/dist/files/FileStorageAdapter.d.ts +5 -0
- package/dist/files/LocalFileStorageAdapter.d.ts +5 -0
- package/dist/files/LocalFileStorageAdapter.js +14 -0
- package/dist/files/S3FileStorageAdapter.d.ts +5 -0
- package/dist/files/S3FileStorageAdapter.js +14 -0
- package/dist/models/BaseModel.d.ts +15 -2
- package/dist/models/BaseModel.js +4 -4
- package/dist/services/AggregateService.d.ts +8 -0
- package/dist/services/AggregateService.js +87 -0
- package/dist/utils/Events.js +1 -1
- package/dist/utils/Logger.d.ts +2 -1
- package/dist/utils/Logger.js +15 -6
- package/dist/utils/SchemaLoader.d.ts +1 -1
- package/dist/utils/SchemaLoader.js +4 -2
- package/package.json +1 -1
package/dist/api/apiRouter.d.ts
CHANGED
|
@@ -6,6 +6,10 @@ export interface APIRouterOptions {
|
|
|
6
6
|
dbAdapter: DatabaseAdapter;
|
|
7
7
|
server?: HttpServer;
|
|
8
8
|
fileStorageAdapter?: FileStorageAdapter;
|
|
9
|
+
imageProxy?: {
|
|
10
|
+
originalBase?: string;
|
|
11
|
+
proxyBase?: string;
|
|
12
|
+
};
|
|
9
13
|
}
|
|
10
14
|
export declare class APIRouter {
|
|
11
15
|
private router;
|
|
@@ -14,9 +18,10 @@ export declare class APIRouter {
|
|
|
14
18
|
private jwtSecret;
|
|
15
19
|
private trustedApiKey;
|
|
16
20
|
private schemaLoader;
|
|
17
|
-
private isDevelopment;
|
|
21
|
+
private get isDevelopment();
|
|
18
22
|
private findRoutesMounted;
|
|
19
23
|
private searchRoutesMounted;
|
|
24
|
+
private batchRoutesMounted;
|
|
20
25
|
private authRoutesInitialized;
|
|
21
26
|
private schemasRouteRegistered;
|
|
22
27
|
private registeredModels;
|
|
@@ -25,6 +30,7 @@ export declare class APIRouter {
|
|
|
25
30
|
private fileStorage?;
|
|
26
31
|
private realtimeService?;
|
|
27
32
|
private fileRoutesMounted;
|
|
33
|
+
private aggregateService?;
|
|
28
34
|
constructor(options: APIRouterOptions);
|
|
29
35
|
getRouter(): express.Router;
|
|
30
36
|
private wireSchemaHotReload;
|
|
@@ -37,6 +43,7 @@ export declare class APIRouter {
|
|
|
37
43
|
private mountRoutes;
|
|
38
44
|
private mountFindRoutes;
|
|
39
45
|
private mountSearchRoutes;
|
|
46
|
+
private mountBatchRoutes;
|
|
40
47
|
private mountFileRoutes;
|
|
41
48
|
private createCtx;
|
|
42
49
|
private getSchema;
|
package/dist/api/apiRouter.js
CHANGED
|
@@ -12,16 +12,23 @@ const SchemaLoader_1 = require("../utils/SchemaLoader");
|
|
|
12
12
|
const DefaultDataInitializer_1 = require("../utils/DefaultDataInitializer");
|
|
13
13
|
const RealtimeService_1 = require("../services/RealtimeService");
|
|
14
14
|
const Events_1 = require("../utils/Events");
|
|
15
|
+
const AggregateService_1 = require("../services/AggregateService");
|
|
15
16
|
const setupFileRoutes_1 = require("./router/setupFileRoutes");
|
|
16
17
|
const setupAuthRoutes_1 = require("./router/setupAuthRoutes");
|
|
17
18
|
const mountCrudRoutes_1 = require("./router/mountCrudRoutes");
|
|
18
19
|
const mountFindRoutes_1 = require("./router/mountFindRoutes");
|
|
19
20
|
const mountSearchRoutes_1 = require("./router/mountSearchRoutes");
|
|
21
|
+
const mountBatchRoutes_1 = require("./router/mountBatchRoutes");
|
|
20
22
|
class APIRouter {
|
|
23
|
+
get isDevelopment() {
|
|
24
|
+
return (process.env.APP_MODE?.toLowerCase() !== 'production' &&
|
|
25
|
+
process.env.NODE_ENV?.toLowerCase() !== 'production');
|
|
26
|
+
}
|
|
21
27
|
constructor(options) {
|
|
22
28
|
this.models = {};
|
|
23
29
|
this.findRoutesMounted = false;
|
|
24
30
|
this.searchRoutesMounted = false;
|
|
31
|
+
this.batchRoutesMounted = false;
|
|
25
32
|
this.authRoutesInitialized = false;
|
|
26
33
|
this.schemasRouteRegistered = false;
|
|
27
34
|
this.registeredModels = new Set();
|
|
@@ -182,7 +189,6 @@ class APIRouter {
|
|
|
182
189
|
}
|
|
183
190
|
return missing;
|
|
184
191
|
};
|
|
185
|
-
this.isDevelopment = process.env.APP_MODE !== 'production';
|
|
186
192
|
this.router = express_1.default.Router();
|
|
187
193
|
this.fileStorage = options.fileStorageAdapter;
|
|
188
194
|
this.dbAdapter = options.dbAdapter;
|
|
@@ -211,6 +217,7 @@ class APIRouter {
|
|
|
211
217
|
this.mountRoutes(initialSchemas);
|
|
212
218
|
this.mountFindRoutes();
|
|
213
219
|
this.mountSearchRoutes();
|
|
220
|
+
this.mountBatchRoutes();
|
|
214
221
|
this.mountFileRoutes();
|
|
215
222
|
await this.syncAllIndexes(initialSchemas);
|
|
216
223
|
const initializer = new DefaultDataInitializer_1.DefaultDataInitializer(this.dbAdapter, this.models);
|
|
@@ -224,6 +231,7 @@ class APIRouter {
|
|
|
224
231
|
Logger_1.default.error('APIRouter', 'Failed to initialize default data', err);
|
|
225
232
|
}
|
|
226
233
|
this.setupNotFoundMiddleware();
|
|
234
|
+
this.aggregateService = new AggregateService_1.AggregateService(this.createCtx());
|
|
227
235
|
this.wireSchemaHotReload();
|
|
228
236
|
};
|
|
229
237
|
if (typeof this.dbAdapter.registerSchemas === 'function') {
|
|
@@ -384,6 +392,13 @@ class APIRouter {
|
|
|
384
392
|
(0, mountSearchRoutes_1.mountSearchRoutes)(this.createCtx());
|
|
385
393
|
this.ensureNotFoundLast();
|
|
386
394
|
}
|
|
395
|
+
mountBatchRoutes() {
|
|
396
|
+
if (this.batchRoutesMounted)
|
|
397
|
+
return;
|
|
398
|
+
this.batchRoutesMounted = true;
|
|
399
|
+
(0, mountBatchRoutes_1.mountBatchRoutes)(this.createCtx());
|
|
400
|
+
this.ensureNotFoundLast();
|
|
401
|
+
}
|
|
387
402
|
mountFileRoutes() {
|
|
388
403
|
if (this.fileRoutesMounted)
|
|
389
404
|
return;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.mountBatchRoutes = mountBatchRoutes;
|
|
4
|
+
const authorize_1 = require("../../policy/authorize");
|
|
5
|
+
const QueryEngine_1 = require("../../database/QueryEngine");
|
|
6
|
+
const utils_1 = require("./utils");
|
|
7
|
+
function mountBatchRoutes(ctx) {
|
|
8
|
+
const { router } = ctx;
|
|
9
|
+
router.post('/_batch', ctx.optionalAuthMiddleware, async (req, res) => {
|
|
10
|
+
try {
|
|
11
|
+
const batchRequest = req.body;
|
|
12
|
+
if (!batchRequest || typeof batchRequest !== 'object') {
|
|
13
|
+
return res.status(400).json({ error: true, message: 'Invalid batch request' });
|
|
14
|
+
}
|
|
15
|
+
const results = {};
|
|
16
|
+
for (const [key, config] of Object.entries(batchRequest)) {
|
|
17
|
+
try {
|
|
18
|
+
const { path, query: queryParams } = config;
|
|
19
|
+
if (!path) {
|
|
20
|
+
results[key] = { error: true, message: 'Missing path' };
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
// Path is usually something like "/doctors" or "/doctors?slug=... "
|
|
24
|
+
// We need to extract the model name
|
|
25
|
+
const modelMatch = path.match(/^\/([^/?]+)/);
|
|
26
|
+
if (!modelMatch) {
|
|
27
|
+
results[key] = { error: true, message: 'Invalid path' };
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const modelName = modelMatch[1].toLowerCase();
|
|
31
|
+
const schema = ctx.getSchema(modelName);
|
|
32
|
+
const model = ctx.getModel(modelName);
|
|
33
|
+
// Prepare pseudo-request for parseQuery
|
|
34
|
+
const pseudoReq = {
|
|
35
|
+
query: queryParams || {},
|
|
36
|
+
user: req.user,
|
|
37
|
+
};
|
|
38
|
+
const { limit, page, skip, sort, projection, sample, _populate } = (0, utils_1.parseQuery)(pseudoReq);
|
|
39
|
+
const filter = QueryEngine_1.QueryEngine.parse(pseudoReq.query, schema);
|
|
40
|
+
const roleStr = req.user?.role;
|
|
41
|
+
const pctx = {
|
|
42
|
+
isAuthenticated: !!req.user,
|
|
43
|
+
role: typeof roleStr === 'string' ? roleStr.toLowerCase() : null,
|
|
44
|
+
userId: req.user?.id || req.user?._id || null,
|
|
45
|
+
isSuperadmin: roleStr?.toLowerCase() === 'superadmin',
|
|
46
|
+
apiKeyOk: true,
|
|
47
|
+
};
|
|
48
|
+
const rdec = (0, authorize_1.authorize)(modelName, 'read', { allowedMethods: schema.allowedMethods, access: schema.access }, pctx);
|
|
49
|
+
if (!rdec.allow) {
|
|
50
|
+
results[key] = { error: true, message: 'Forbidden' };
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const finalFilter = { ...filter, ...(rdec.queryFilter || {}) };
|
|
54
|
+
// Handle countBy if present in query
|
|
55
|
+
const countBy = String(pseudoReq.query.countBy || '').trim();
|
|
56
|
+
if (countBy && schema.attributes[countBy]) {
|
|
57
|
+
const counts = await ctx.dbAdapter.countBy(schema.modelName, countBy, finalFilter, schema);
|
|
58
|
+
results[key] = { success: true, counts };
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const totalRows = await model.count(finalFilter);
|
|
62
|
+
const rows = await model.read(finalFilter, limit, skip, !!rdec.exposePrivate, {
|
|
63
|
+
sort,
|
|
64
|
+
projection,
|
|
65
|
+
sample,
|
|
66
|
+
_populate,
|
|
67
|
+
});
|
|
68
|
+
const data = rdec.exposePrivate
|
|
69
|
+
? (0, authorize_1.applyReadMaskMany)(rows, rdec.sensitiveMask)
|
|
70
|
+
: (0, authorize_1.applyReadMaskMany)(rows, rdec.readMask);
|
|
71
|
+
results[key] = {
|
|
72
|
+
success: true,
|
|
73
|
+
data,
|
|
74
|
+
pagination: { totalRows, page, limit },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
results[key] = { error: true, message: err.message || 'Error' };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return res.status(200).json({
|
|
82
|
+
success: true,
|
|
83
|
+
results,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
return res.status(500).json({ error: true, message: err.message || 'Batch execution failed' });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -296,6 +296,14 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
296
296
|
return res.status(403).json({ error: true, message: 'forbidden' });
|
|
297
297
|
}
|
|
298
298
|
const finalFilter = (0, authorize_1.andFilter)(filter, rdec.queryFilter || {});
|
|
299
|
+
const countBy = String(req.query.countBy ?? '').trim();
|
|
300
|
+
if (countBy && schema.attributes[countBy]) {
|
|
301
|
+
const counts = await ctx.dbAdapter.countBy(schema.modelName, countBy, finalFilter, schema);
|
|
302
|
+
return res.status(200).json({
|
|
303
|
+
success: true,
|
|
304
|
+
counts,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
299
307
|
const isUsersModel = modelNameLC === 'user' ||
|
|
300
308
|
modelNameLC === 'users' ||
|
|
301
309
|
(schema?.modelName ?? '').toLowerCase() === 'users';
|
|
@@ -640,7 +648,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
640
648
|
}
|
|
641
649
|
let updatedChild = before;
|
|
642
650
|
if (Object.keys(childUpd).length) {
|
|
643
|
-
updatedChild = await model.update(req.params.id, childUpd);
|
|
651
|
+
updatedChild = await model.update(req.params.id, childUpd, false, before);
|
|
644
652
|
}
|
|
645
653
|
if (targetBaseId && Object.keys(baseUpd).length) {
|
|
646
654
|
await baseModel.update(targetBaseId, baseUpd);
|
|
@@ -687,7 +695,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
687
695
|
})),
|
|
688
696
|
});
|
|
689
697
|
}
|
|
690
|
-
const updatedRecord = await model.update(req.params.id, upd);
|
|
698
|
+
const updatedRecord = await model.update(req.params.id, upd, false, before);
|
|
691
699
|
let responseDoc = updatedRecord;
|
|
692
700
|
if (udec.exposePrivate && updatedRecord?.id) {
|
|
693
701
|
const refetched = await model.read({ id: updatedRecord.id }, 1, 0, true);
|
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.setupFileRoutes = setupFileRoutes;
|
|
7
7
|
const multer_1 = __importDefault(require("multer"));
|
|
8
|
+
const Logger_1 = __importDefault(require("../../utils/Logger"));
|
|
8
9
|
function setupFileRoutes(ctx) {
|
|
9
10
|
if (!ctx.fileStorage)
|
|
10
11
|
return;
|
|
@@ -53,6 +54,46 @@ function setupFileRoutes(ctx) {
|
|
|
53
54
|
.json({ error: true, message: err?.message ?? 'Upload failed' });
|
|
54
55
|
}
|
|
55
56
|
});
|
|
57
|
+
ctx.router.get('/files/:key(*)', async (req, res) => {
|
|
58
|
+
try {
|
|
59
|
+
let key = String(req.params.key || '');
|
|
60
|
+
if (!key) {
|
|
61
|
+
return res.status(400).json({ error: true, message: 'Key is required' });
|
|
62
|
+
}
|
|
63
|
+
// Ensure no leading slash for storage providers
|
|
64
|
+
key = key.replace(/^\/+/, '');
|
|
65
|
+
Logger_1.default.info('FileProxy', `Fetching file from storage: ${key}`);
|
|
66
|
+
const { body, contentType, size } = await ctx.fileStorage.download(key);
|
|
67
|
+
if (contentType)
|
|
68
|
+
res.setHeader('Content-Type', contentType);
|
|
69
|
+
if (size)
|
|
70
|
+
res.setHeader('Content-Length', size);
|
|
71
|
+
// Cache for 1 year if it's an image
|
|
72
|
+
if (contentType?.startsWith('image/')) {
|
|
73
|
+
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
77
|
+
}
|
|
78
|
+
if (body && typeof body.pipe === 'function') {
|
|
79
|
+
body.pipe(res);
|
|
80
|
+
}
|
|
81
|
+
else if (body && typeof body.transformToByteArray === 'function') {
|
|
82
|
+
const data = await body.transformToByteArray();
|
|
83
|
+
res.send(Buffer.from(data));
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
res.send(body);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
Logger_1.default.warn('FileProxy', `Failed to fetch file ${req.params.key}: ${err.message}`);
|
|
91
|
+
if (err.name === 'NoSuchKey' || err.code === 'NoSuchKey' || err.name === 'NotFound' || err.code === 'NotFound') {
|
|
92
|
+
return res.status(404).json({ error: true, message: 'File not found in storage' });
|
|
93
|
+
}
|
|
94
|
+
return res.status(500).json({ error: true, message: err?.message ?? 'Download failed' });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
56
97
|
ctx.router.post('/files', fileAuthMiddleware, upload.any(), async (req, res) => {
|
|
57
98
|
try {
|
|
58
99
|
const files = req.files ?? [];
|
|
@@ -55,6 +55,8 @@ export type ParsedQuery = {
|
|
|
55
55
|
skip: number;
|
|
56
56
|
projection?: Record<string, 1>;
|
|
57
57
|
sort: SortSpec;
|
|
58
|
+
sample?: number;
|
|
59
|
+
_populate?: Record<string, Record<string, 1>>;
|
|
58
60
|
};
|
|
59
61
|
export declare function parseQuery(req: Request, allowedKeys?: Set<string>): ParsedQuery;
|
|
60
62
|
export declare function extractIds(val: unknown): string[];
|
package/dist/api/router/utils.js
CHANGED
|
@@ -287,7 +287,23 @@ function parseQuery(req, allowedKeys) {
|
|
|
287
287
|
// NOTE: do NOT force a fallback like '-createdAt' here;
|
|
288
288
|
// parseSort will apply { createdAt: -1 } only if no sort is provided.
|
|
289
289
|
const sort = parseSort(req.query.sort, req.query.sortType, allowedKeys);
|
|
290
|
-
|
|
290
|
+
const sampleRaw = req.query.sample;
|
|
291
|
+
const sample = sampleRaw ? Math.max(Number.parseInt(String(sampleRaw), 10) || 0, 0) : undefined;
|
|
292
|
+
const populateMap = {};
|
|
293
|
+
const populateRaw = String(req.query.populate ?? '').trim();
|
|
294
|
+
if (populateRaw) {
|
|
295
|
+
const parts = populateRaw.split(';');
|
|
296
|
+
for (const part of parts) {
|
|
297
|
+
const [field, subFieldsRaw] = part.split(':');
|
|
298
|
+
if (field && subFieldsRaw) {
|
|
299
|
+
const subFields = subFieldsRaw.split(',').map(s => s.trim()).filter(Boolean);
|
|
300
|
+
if (subFields.length > 0) {
|
|
301
|
+
populateMap[field] = Object.fromEntries(subFields.map(f => [f, 1]));
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return { limit, page, skip, projection, sort, sample, _populate: populateMap };
|
|
291
307
|
}
|
|
292
308
|
function extractIds(val) {
|
|
293
309
|
if (val == null)
|
|
@@ -15,6 +15,7 @@ export interface DatabaseAdapter {
|
|
|
15
15
|
* Returns a map ofparentId -> firstRelatedId
|
|
16
16
|
*/
|
|
17
17
|
findFirstRelatedIds(collection: string, field: string, ids: string[]): Promise<Map<string, string>>;
|
|
18
|
+
countBy(collection: string, field: string, query: any, schema: Schema): Promise<Record<string, number>>;
|
|
18
19
|
syncIndexes?(modelName: string, desired: FieldIndexSpec): Promise<void>;
|
|
19
20
|
cleanupUnusedFields?(schemas: Record<string, Schema>): Promise<Record<string, string[]>>;
|
|
20
21
|
isExclusiveProjection(projection: Record<string, 0 | 1> | undefined, schema: Schema | undefined): boolean;
|
|
@@ -10,6 +10,7 @@ export declare class NMAdapter implements DatabaseAdapter {
|
|
|
10
10
|
private repositories;
|
|
11
11
|
private entitySchemas;
|
|
12
12
|
private registeredSchemas;
|
|
13
|
+
private imageProxyOptions;
|
|
13
14
|
constructor(options: any);
|
|
14
15
|
connect(): Promise<void>;
|
|
15
16
|
disconnect(): Promise<void>;
|
|
@@ -22,12 +23,16 @@ export declare class NMAdapter implements DatabaseAdapter {
|
|
|
22
23
|
private coercePayloadForStorage;
|
|
23
24
|
read(collection: string, query: any, limit?: number, skip?: number, schemaDefinition?: NextMinSchema, includePrivateFields?: boolean, options?: ReadOptions): Promise<any[]>;
|
|
24
25
|
private prepareResult;
|
|
26
|
+
private transformUrls;
|
|
25
27
|
private idToString;
|
|
26
28
|
private convertToObjectId;
|
|
29
|
+
private discoverInverseRelation;
|
|
30
|
+
private hydrateDynamicAggregates;
|
|
27
31
|
private hydrateRelations;
|
|
28
32
|
update(collection: string, targetId: any, data: any, schemaDefinition?: NextMinSchema): Promise<any>;
|
|
29
33
|
delete(collection: string, targetId: any, schemaDefinition?: NextMinSchema): Promise<any>;
|
|
30
34
|
count(collection: string, query: any, schemaDefinition?: NextMinSchema): Promise<number>;
|
|
35
|
+
countBy(collection: string, field: string, query: any, schema?: NextMinSchema): Promise<Record<string, number>>;
|
|
31
36
|
findFirstRelatedIds(collection: string, field: string, ids: string[]): Promise<Map<string, string>>;
|
|
32
37
|
/**
|
|
33
38
|
* Centralized Nested Search Resolver
|
|
@@ -19,6 +19,38 @@ class NMAdapter {
|
|
|
19
19
|
this.repositories = new Map();
|
|
20
20
|
this.entitySchemas = new Map();
|
|
21
21
|
this.registeredSchemas = {};
|
|
22
|
+
this.imageProxyOptions = {};
|
|
23
|
+
const isValidUrl = (url) => typeof url === 'string' && url.startsWith('http');
|
|
24
|
+
let imageProxy = options.imageProxy;
|
|
25
|
+
if (!imageProxy || Object.keys(imageProxy).length === 0) {
|
|
26
|
+
imageProxy = { proxyBase: '/images' };
|
|
27
|
+
}
|
|
28
|
+
if (imageProxy && imageProxy.proxyBase) {
|
|
29
|
+
this.imageProxyOptions = { ...imageProxy };
|
|
30
|
+
// Auto-detect originalBase if missing but s3/r2 config exists
|
|
31
|
+
if (!this.imageProxyOptions.originalBase) {
|
|
32
|
+
const s3 = options.s3 || {
|
|
33
|
+
bucket: process.env.S3_BUCKET,
|
|
34
|
+
region: process.env.S3_REGION,
|
|
35
|
+
endpoint: process.env.S3_ENDPOINT,
|
|
36
|
+
publicBaseUrl: process.env.S3_PUBLIC_BASE_URL
|
|
37
|
+
};
|
|
38
|
+
if (s3.publicBaseUrl) {
|
|
39
|
+
this.imageProxyOptions.originalBase = s3.publicBaseUrl.replace(/\/+$/, '');
|
|
40
|
+
}
|
|
41
|
+
else if (s3.bucket) {
|
|
42
|
+
if (isValidUrl(s3.endpoint)) {
|
|
43
|
+
this.imageProxyOptions.originalBase = `${s3.endpoint.replace(/\/+$/, '')}/${s3.bucket}`;
|
|
44
|
+
}
|
|
45
|
+
else if (s3.region) {
|
|
46
|
+
this.imageProxyOptions.originalBase = `https://${s3.bucket}.s3.${s3.region}.amazonaws.com`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (this.imageProxyOptions.originalBase && this.imageProxyOptions.proxyBase) {
|
|
51
|
+
Logger_1.default.info('NMAdapter', `Image proxy enabled: ${this.imageProxyOptions.originalBase} -> ${this.imageProxyOptions.proxyBase}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
22
54
|
this.dataSource = new typeorm_1.DataSource({
|
|
23
55
|
...options,
|
|
24
56
|
entities: [], // Will be populated dynamically
|
|
@@ -130,11 +162,13 @@ class NMAdapter {
|
|
|
130
162
|
},
|
|
131
163
|
createdAt: {
|
|
132
164
|
type: 'datetime',
|
|
165
|
+
createDate: true,
|
|
133
166
|
default: () => 'CURRENT_TIMESTAMP',
|
|
134
167
|
nullable: true,
|
|
135
168
|
},
|
|
136
169
|
updatedAt: {
|
|
137
170
|
type: 'datetime',
|
|
171
|
+
updateDate: true,
|
|
138
172
|
default: () => 'CURRENT_TIMESTAMP',
|
|
139
173
|
nullable: true,
|
|
140
174
|
},
|
|
@@ -159,6 +193,9 @@ class NMAdapter {
|
|
|
159
193
|
continue; // Base model protects this field; skip column in child
|
|
160
194
|
}
|
|
161
195
|
}
|
|
196
|
+
const head = Array.isArray(attr) ? attr[0] : attr;
|
|
197
|
+
if (head?.type === 'virtual')
|
|
198
|
+
continue;
|
|
162
199
|
const typeORMType = this.mapType(attr);
|
|
163
200
|
const isUnique = attr.unique === true;
|
|
164
201
|
const isSparse = attr.sparse === true;
|
|
@@ -219,6 +256,12 @@ class NMAdapter {
|
|
|
219
256
|
async create(collection, data, schemaDefinition) {
|
|
220
257
|
const repo = this.getRepository(collection);
|
|
221
258
|
const coerced = this.coercePayloadForStorage(data, schemaDefinition);
|
|
259
|
+
// Ensure timestamps are set if not provided (especially for MongoDB)
|
|
260
|
+
const now = new Date();
|
|
261
|
+
if (!coerced.createdAt)
|
|
262
|
+
coerced.createdAt = now;
|
|
263
|
+
if (!coerced.updatedAt)
|
|
264
|
+
coerced.updatedAt = now;
|
|
222
265
|
const created = await repo.save(coerced);
|
|
223
266
|
return this.prepareResult(created);
|
|
224
267
|
}
|
|
@@ -260,7 +303,16 @@ class NMAdapter {
|
|
|
260
303
|
const hasRootProj = this.isExclusiveProjection(options.projection, schemaDefinition);
|
|
261
304
|
if (hasRootProj) {
|
|
262
305
|
const dbProjection = {};
|
|
306
|
+
// Always include ID for hydration purposes
|
|
307
|
+
if (isMongo)
|
|
308
|
+
dbProjection._id = true;
|
|
309
|
+
else
|
|
310
|
+
dbProjection.id = true;
|
|
263
311
|
for (const [key, value] of Object.entries(options.projection)) {
|
|
312
|
+
// Skip dynamic aggregate fields like model[count] for the DB query
|
|
313
|
+
if (key.includes('[') && key.endsWith(']')) {
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
264
316
|
if (key.includes('.') && schemaDefinition) {
|
|
265
317
|
const root = key.split('.')[0];
|
|
266
318
|
const attr = schemaDefinition.attributes[root];
|
|
@@ -288,7 +340,15 @@ class NMAdapter {
|
|
|
288
340
|
}
|
|
289
341
|
}
|
|
290
342
|
let results;
|
|
291
|
-
if (isMongo &&
|
|
343
|
+
if (isMongo && options?.sample) {
|
|
344
|
+
const mongoRepo = repo.manager.getMongoRepository(repo.metadata.name);
|
|
345
|
+
const aggregation = [{ $match: where }];
|
|
346
|
+
aggregation.push({ $sample: { size: options.sample } });
|
|
347
|
+
if (findOptions.projection)
|
|
348
|
+
aggregation.push({ $project: findOptions.projection });
|
|
349
|
+
results = await mongoRepo.aggregate(aggregation).toArray();
|
|
350
|
+
}
|
|
351
|
+
else if (isMongo && findOptions.projection) {
|
|
292
352
|
const mongoRepo = repo.manager.getMongoRepository(repo.metadata.name);
|
|
293
353
|
const cursor = mongoRepo.createCursor(where);
|
|
294
354
|
cursor.project(findOptions.projection);
|
|
@@ -301,11 +361,32 @@ class NMAdapter {
|
|
|
301
361
|
results = await cursor.toArray();
|
|
302
362
|
}
|
|
303
363
|
else {
|
|
304
|
-
|
|
364
|
+
if (options?.sample && !isMongo) {
|
|
365
|
+
const qb = repo.createQueryBuilder('entity');
|
|
366
|
+
qb.where(where);
|
|
367
|
+
if (limit)
|
|
368
|
+
qb.take(limit);
|
|
369
|
+
if (skip)
|
|
370
|
+
qb.skip(skip);
|
|
371
|
+
const dbType = this.dataSource.options.type;
|
|
372
|
+
if (dbType === 'postgres' || dbType === 'sqlite') {
|
|
373
|
+
qb.orderBy('RANDOM()');
|
|
374
|
+
}
|
|
375
|
+
else if (dbType === 'mysql' || dbType === 'mariadb') {
|
|
376
|
+
qb.orderBy('RAND()');
|
|
377
|
+
}
|
|
378
|
+
else if (dbType === 'mssql') {
|
|
379
|
+
qb.orderBy('NEWID()');
|
|
380
|
+
}
|
|
381
|
+
results = await qb.getMany();
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
results = await repo.find(findOptions);
|
|
385
|
+
}
|
|
305
386
|
}
|
|
306
387
|
const mappedResults = results.map(r => this.prepareResult(r));
|
|
307
388
|
if (schemaDefinition) {
|
|
308
|
-
await this.hydrateRelations(mappedResults, schemaDefinition, options?.depth ?? 0, options
|
|
389
|
+
await this.hydrateRelations(mappedResults, schemaDefinition, options?.depth ?? 0, options);
|
|
309
390
|
}
|
|
310
391
|
return mappedResults;
|
|
311
392
|
}
|
|
@@ -324,8 +405,40 @@ class NMAdapter {
|
|
|
324
405
|
if (item.id != null)
|
|
325
406
|
item.id = String(item.id);
|
|
326
407
|
}
|
|
408
|
+
// Apply image URL proxying/transformation if configured
|
|
409
|
+
if (this.imageProxyOptions.originalBase && this.imageProxyOptions.proxyBase) {
|
|
410
|
+
this.transformUrls(item);
|
|
411
|
+
}
|
|
327
412
|
return item;
|
|
328
413
|
}
|
|
414
|
+
transformUrls(obj) {
|
|
415
|
+
if (!obj || typeof obj !== 'object')
|
|
416
|
+
return;
|
|
417
|
+
const { originalBase, proxyBase } = this.imageProxyOptions;
|
|
418
|
+
if (!originalBase || !proxyBase)
|
|
419
|
+
return;
|
|
420
|
+
for (const key in obj) {
|
|
421
|
+
const val = obj[key];
|
|
422
|
+
if (typeof val === 'string') {
|
|
423
|
+
if (val.startsWith(originalBase)) {
|
|
424
|
+
obj[key] = val.replace(originalBase, proxyBase);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
else if (Array.isArray(val)) {
|
|
428
|
+
for (let i = 0; i < val.length; i++) {
|
|
429
|
+
if (typeof val[i] === 'string' && val[i].startsWith(originalBase)) {
|
|
430
|
+
val[i] = val[i].replace(originalBase, proxyBase);
|
|
431
|
+
}
|
|
432
|
+
else if (val[i] && typeof val[i] === 'object') {
|
|
433
|
+
this.transformUrls(val[i]);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
else if (val && typeof val === 'object') {
|
|
438
|
+
this.transformUrls(val);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
329
442
|
idToString(val) {
|
|
330
443
|
if (val == null)
|
|
331
444
|
return '';
|
|
@@ -358,8 +471,15 @@ class NMAdapter {
|
|
|
358
471
|
return this.convertToObjectId(extractedId);
|
|
359
472
|
}
|
|
360
473
|
try {
|
|
361
|
-
if (
|
|
362
|
-
|
|
474
|
+
if (this.dataSource.options.type === 'mongodb') {
|
|
475
|
+
if (typeof val === 'string' && val.length === 24 && /^[0-9a-fA-F]{24}$/.test(val)) {
|
|
476
|
+
return new mongodb_1.ObjectId(val);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
const n = Number(val);
|
|
481
|
+
if (!isNaN(n) && String(val).trim() !== '')
|
|
482
|
+
return n;
|
|
363
483
|
}
|
|
364
484
|
return val;
|
|
365
485
|
}
|
|
@@ -367,9 +487,87 @@ class NMAdapter {
|
|
|
367
487
|
return val;
|
|
368
488
|
}
|
|
369
489
|
}
|
|
370
|
-
|
|
490
|
+
discoverInverseRelation(targetModelName, sourceModelHint) {
|
|
491
|
+
const targetLc = targetModelName.toLowerCase();
|
|
492
|
+
const sourceLc = sourceModelHint.toLowerCase();
|
|
493
|
+
// 1. Find a schema that matches sourceModelHint (plural or singular)
|
|
494
|
+
let sourceSchema;
|
|
495
|
+
// Try exact match first
|
|
496
|
+
sourceSchema = this.registeredSchemas[sourceLc];
|
|
497
|
+
// Try common plural/singular variations
|
|
498
|
+
if (!sourceSchema) {
|
|
499
|
+
const variations = [
|
|
500
|
+
sourceLc + 's',
|
|
501
|
+
sourceLc + 'es',
|
|
502
|
+
sourceLc.endsWith('y') ? sourceLc.slice(0, -1) + 'ies' : sourceLc,
|
|
503
|
+
sourceLc.endsWith('ies') ? sourceLc.slice(0, -3) + 'y' : sourceLc,
|
|
504
|
+
sourceLc.endsWith('s') ? sourceLc.slice(0, -1) : sourceLc
|
|
505
|
+
];
|
|
506
|
+
for (const v of variations) {
|
|
507
|
+
if (this.registeredSchemas[v]) {
|
|
508
|
+
sourceSchema = this.registeredSchemas[v];
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (!sourceSchema)
|
|
514
|
+
return null;
|
|
515
|
+
// 2. Find which field in sourceSchema points to targetModelName
|
|
516
|
+
for (const [field, attr] of Object.entries(sourceSchema.attributes)) {
|
|
517
|
+
const a = Array.isArray(attr) ? attr[0] : attr;
|
|
518
|
+
if (a.ref?.toLowerCase() === targetLc) {
|
|
519
|
+
return { sourceModel: sourceSchema.modelName, foreignField: field };
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
async hydrateDynamicAggregates(results, schema, projection) {
|
|
525
|
+
const promises = [];
|
|
526
|
+
const idsToFetch = results.map(r => r.id).filter(v => v != null);
|
|
527
|
+
if (idsToFetch.length === 0)
|
|
528
|
+
return;
|
|
529
|
+
for (const [projKey, projVal] of Object.entries(projection)) {
|
|
530
|
+
if (projVal !== 1)
|
|
531
|
+
continue;
|
|
532
|
+
if (!projKey.includes('[') || !projKey.endsWith(']'))
|
|
533
|
+
continue;
|
|
534
|
+
const parts = projKey.split('[');
|
|
535
|
+
const modelHint = parts[0];
|
|
536
|
+
const op = parts[1].slice(0, -1);
|
|
537
|
+
if (op !== 'count')
|
|
538
|
+
continue;
|
|
539
|
+
const relation = this.discoverInverseRelation(schema.modelName, modelHint);
|
|
540
|
+
if (!relation) {
|
|
541
|
+
Logger_1.default.warn('NMAdapter', `No inverse relation found for ${modelHint} pointing to ${schema.modelName}`);
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
promises.push((async () => {
|
|
545
|
+
try {
|
|
546
|
+
const sourceSchema = this.registeredSchemas[relation.sourceModel.toLowerCase()];
|
|
547
|
+
const counts = await this.countBy(relation.sourceModel, relation.foreignField, { [relation.foreignField]: { $in: idsToFetch } }, sourceSchema);
|
|
548
|
+
const targetKey = `${modelHint}Count`;
|
|
549
|
+
results.forEach(r => {
|
|
550
|
+
const lid = this.idToString(r.id);
|
|
551
|
+
r[targetKey] = counts[lid] || 0;
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
catch (err) {
|
|
555
|
+
Logger_1.default.warn('NMAdapter', `Failed to fetch dynamic aggregate ${projKey} for ${schema.modelName}: ${err}`);
|
|
556
|
+
}
|
|
557
|
+
})());
|
|
558
|
+
}
|
|
559
|
+
if (promises.length > 0) {
|
|
560
|
+
await Promise.all(promises);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
async hydrateRelations(results, schema, depth = 0, options) {
|
|
371
564
|
if (!results.length || depth > 2)
|
|
372
565
|
return;
|
|
566
|
+
const projection = options?.projection;
|
|
567
|
+
// --- Dynamic Aggregates (e.g. model[count]) ---
|
|
568
|
+
if (projection) {
|
|
569
|
+
await this.hydrateDynamicAggregates(results, schema, projection);
|
|
570
|
+
}
|
|
373
571
|
// 1) Handle inheritance: if schema extends another, fetch base records
|
|
374
572
|
if (schema.extends) {
|
|
375
573
|
const baseLc = schema.extends.toLowerCase();
|
|
@@ -436,6 +634,29 @@ class NMAdapter {
|
|
|
436
634
|
}
|
|
437
635
|
// 2) Handle standard ObjectId hydration & 3) json-group in parallel
|
|
438
636
|
const hydrationPromises = [];
|
|
637
|
+
// --- Virtual fields (Inverse relationship counts) ---
|
|
638
|
+
const virtualFields = Object.entries(schema.attributes).filter(([key, attr]) => {
|
|
639
|
+
const a = Array.isArray(attr) ? attr[0] : attr;
|
|
640
|
+
return a?.type === 'virtual' && a?.op === 'count';
|
|
641
|
+
});
|
|
642
|
+
for (const [key, attr] of virtualFields) {
|
|
643
|
+
hydrationPromises.push((async () => {
|
|
644
|
+
const a = Array.isArray(attr) ? attr[0] : attr;
|
|
645
|
+
const refModel = a.ref;
|
|
646
|
+
const foreignField = a.foreignField;
|
|
647
|
+
const localField = a.localField || 'id';
|
|
648
|
+
if (!refModel || !foreignField)
|
|
649
|
+
return;
|
|
650
|
+
const idsToFetch = results.map(r => r[localField]).filter(v => v != null);
|
|
651
|
+
if (idsToFetch.length === 0)
|
|
652
|
+
return;
|
|
653
|
+
const counts = await this.countBy(refModel, foreignField, { [foreignField]: { $in: idsToFetch } });
|
|
654
|
+
results.forEach(r => {
|
|
655
|
+
const lid = this.idToString(r[localField]);
|
|
656
|
+
r[key] = counts[lid] || 0;
|
|
657
|
+
});
|
|
658
|
+
})());
|
|
659
|
+
}
|
|
439
660
|
// --- Standard Relations ---
|
|
440
661
|
const relFields = Object.entries(schema.attributes).filter(([key, attr]) => {
|
|
441
662
|
if (key === 'baseId')
|
|
@@ -472,13 +693,16 @@ class NMAdapter {
|
|
|
472
693
|
}
|
|
473
694
|
if (idsToFetch.size === 0)
|
|
474
695
|
return;
|
|
475
|
-
let subProjection =
|
|
476
|
-
if (
|
|
696
|
+
let subProjection = options?._populate?.[key];
|
|
697
|
+
if (subProjection) {
|
|
698
|
+
subProjection = { ...subProjection, id: 1 };
|
|
699
|
+
}
|
|
700
|
+
if (!subProjection && projection) {
|
|
477
701
|
const prefix = `${key}.`;
|
|
478
702
|
for (const [pk, pv] of Object.entries(projection)) {
|
|
479
703
|
if (pk.startsWith(prefix)) {
|
|
480
704
|
if (!subProjection)
|
|
481
|
-
subProjection = {};
|
|
705
|
+
subProjection = { id: 1 };
|
|
482
706
|
subProjection[pk.slice(prefix.length)] = pv;
|
|
483
707
|
}
|
|
484
708
|
}
|
|
@@ -554,6 +778,8 @@ class NMAdapter {
|
|
|
554
778
|
const coerced = this.coercePayloadForStorage(data, schemaDefinition);
|
|
555
779
|
// Strip ID fields from data to prevent primary key conflicts
|
|
556
780
|
const { id: _unusedId, _id: _unusedRawId, ...updateData } = coerced;
|
|
781
|
+
const now = new Date();
|
|
782
|
+
updateData.updatedAt = now;
|
|
557
783
|
if (isMongo) {
|
|
558
784
|
// Using explicit getMongoRepository and updateOne prevents accidental bulk updates
|
|
559
785
|
// that targetBy filter {} could cause in standard TypeORM .update()
|
|
@@ -572,6 +798,8 @@ class NMAdapter {
|
|
|
572
798
|
setObj[k] = updateData[k] !== undefined ? updateData[k] : v;
|
|
573
799
|
}
|
|
574
800
|
}
|
|
801
|
+
// Always update the updatedAt field
|
|
802
|
+
setObj.updatedAt = now;
|
|
575
803
|
const updatePayload = {};
|
|
576
804
|
if (Object.keys(setObj).length > 0)
|
|
577
805
|
updatePayload.$set = setObj;
|
|
@@ -622,6 +850,42 @@ class NMAdapter {
|
|
|
622
850
|
}
|
|
623
851
|
return total;
|
|
624
852
|
}
|
|
853
|
+
async countBy(collection, field, query, schema) {
|
|
854
|
+
const repo = this.getRepository(collection);
|
|
855
|
+
const where = this.transformQuery(query, schema);
|
|
856
|
+
const counts = {};
|
|
857
|
+
if (this.dataSource.options.type === 'mongodb') {
|
|
858
|
+
const mongoRepo = repo.manager.getMongoRepository(repo.metadata.name);
|
|
859
|
+
// Determine if field is an array from schema to use $unwind
|
|
860
|
+
const isArray = Array.isArray(schema?.attributes?.[field]);
|
|
861
|
+
const pipeline = [{ $match: where }];
|
|
862
|
+
if (isArray) {
|
|
863
|
+
pipeline.push({ $unwind: `$${field}` });
|
|
864
|
+
}
|
|
865
|
+
pipeline.push({ $group: { _id: `$${field}`, count: { $sum: 1 } } });
|
|
866
|
+
const results = await mongoRepo.aggregate(pipeline).toArray();
|
|
867
|
+
results.forEach((r) => {
|
|
868
|
+
if (r._id != null)
|
|
869
|
+
counts[this.idToString(r._id)] = r.count;
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
else {
|
|
873
|
+
const results = await repo
|
|
874
|
+
.createQueryBuilder('entity')
|
|
875
|
+
.select(`entity.${field}`, 'key')
|
|
876
|
+
.addSelect('COUNT(*)', 'count')
|
|
877
|
+
.where(where)
|
|
878
|
+
.groupBy(`entity.${field}`)
|
|
879
|
+
.getRawMany();
|
|
880
|
+
results.forEach((r) => {
|
|
881
|
+
const k = r.key || r.KEY || r[`entity_${field}`];
|
|
882
|
+
if (k != null) {
|
|
883
|
+
counts[this.idToString(k)] = parseInt(r.count || r.COUNT || 0, 10);
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
return counts;
|
|
888
|
+
}
|
|
625
889
|
async findFirstRelatedIds(collection, field, ids) {
|
|
626
890
|
const repo = this.getRepository(collection);
|
|
627
891
|
const map = new Map();
|
|
@@ -28,6 +28,11 @@ export interface UploadResult {
|
|
|
28
28
|
export interface FileStorageAdapter {
|
|
29
29
|
readonly name: FileProvider;
|
|
30
30
|
upload(input: UploadPayload): Promise<UploadResult>;
|
|
31
|
+
download(key: string): Promise<{
|
|
32
|
+
body: any;
|
|
33
|
+
contentType?: string;
|
|
34
|
+
size?: number;
|
|
35
|
+
}>;
|
|
31
36
|
delete(key: string): Promise<{
|
|
32
37
|
deleted: boolean;
|
|
33
38
|
}>;
|
|
@@ -9,6 +9,11 @@ export declare class LocalFileStorageAdapter implements FileStorageAdapter {
|
|
|
9
9
|
private publicUrlPrefix;
|
|
10
10
|
constructor(options: LocalFileStorageOptions);
|
|
11
11
|
upload(input: UploadPayload): Promise<UploadResult>;
|
|
12
|
+
download(key: string): Promise<{
|
|
13
|
+
body: any;
|
|
14
|
+
contentType?: string;
|
|
15
|
+
size?: number;
|
|
16
|
+
}>;
|
|
12
17
|
delete(key: string): Promise<{
|
|
13
18
|
deleted: boolean;
|
|
14
19
|
}>;
|
|
@@ -43,6 +43,20 @@ class LocalFileStorageAdapter {
|
|
|
43
43
|
contentType: input.contentType,
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
|
+
async download(key) {
|
|
47
|
+
const filePath = path_1.default.join(this.uploadDir, key);
|
|
48
|
+
if (!fs_1.default.existsSync(filePath)) {
|
|
49
|
+
throw new Error('File not found');
|
|
50
|
+
}
|
|
51
|
+
const stats = fs_1.default.statSync(filePath);
|
|
52
|
+
const body = fs_1.default.createReadStream(filePath);
|
|
53
|
+
return {
|
|
54
|
+
body,
|
|
55
|
+
size: stats.size,
|
|
56
|
+
// Note: Local storage doesn't store contentType natively in metadata here,
|
|
57
|
+
// but we could infer it if needed.
|
|
58
|
+
};
|
|
59
|
+
}
|
|
46
60
|
async delete(key) {
|
|
47
61
|
const filePath = path_1.default.join(this.uploadDir, key);
|
|
48
62
|
if (fs_1.default.existsSync(filePath)) {
|
|
@@ -22,6 +22,11 @@ export declare class S3FileStorageAdapter implements FileStorageAdapter {
|
|
|
22
22
|
private readonly publicBaseUrl?;
|
|
23
23
|
constructor(opts: S3AdapterOptions);
|
|
24
24
|
upload(input: UploadPayload): Promise<UploadResult>;
|
|
25
|
+
download(key: string): Promise<{
|
|
26
|
+
body: any;
|
|
27
|
+
contentType?: string;
|
|
28
|
+
size?: number;
|
|
29
|
+
}>;
|
|
25
30
|
delete(key: string): Promise<{
|
|
26
31
|
deleted: boolean;
|
|
27
32
|
}>;
|
|
@@ -55,6 +55,20 @@ class S3FileStorageAdapter {
|
|
|
55
55
|
metadata: input.metadata,
|
|
56
56
|
};
|
|
57
57
|
}
|
|
58
|
+
async download(key) {
|
|
59
|
+
try {
|
|
60
|
+
const res = await this.s3.send(new client_s3_1.GetObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
61
|
+
return {
|
|
62
|
+
body: res.Body,
|
|
63
|
+
contentType: res.ContentType,
|
|
64
|
+
size: res.ContentLength,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
// Re-throw so the caller can log it with more context
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
58
72
|
async delete(key) {
|
|
59
73
|
await this.s3.send(new client_s3_1.DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
60
74
|
return { deleted: true };
|
|
@@ -5,6 +5,8 @@ export type ReadOptions = {
|
|
|
5
5
|
sort?: SortSpec;
|
|
6
6
|
projection?: Record<string, 0 | 1>;
|
|
7
7
|
depth?: number;
|
|
8
|
+
sample?: number;
|
|
9
|
+
_populate?: Record<string, Record<string, 1>>;
|
|
8
10
|
};
|
|
9
11
|
export interface Attributes {
|
|
10
12
|
type: string;
|
|
@@ -21,6 +23,9 @@ export interface Attributes {
|
|
|
21
23
|
rich?: boolean;
|
|
22
24
|
searchBy?: string;
|
|
23
25
|
show?: string;
|
|
26
|
+
op?: 'count' | 'sum' | 'avg' | 'min' | 'max';
|
|
27
|
+
foreignField?: string;
|
|
28
|
+
localField?: string;
|
|
24
29
|
}
|
|
25
30
|
export interface CustomAction {
|
|
26
31
|
label: string;
|
|
@@ -47,15 +52,23 @@ export interface Schema {
|
|
|
47
52
|
extends?: string;
|
|
48
53
|
actions?: CustomAction[];
|
|
49
54
|
access?: any;
|
|
55
|
+
aggregates?: AggregateSpec[];
|
|
56
|
+
}
|
|
57
|
+
export interface AggregateSpec {
|
|
58
|
+
sourceModel: string;
|
|
59
|
+
foreignField: string;
|
|
60
|
+
op: 'count' | 'avg' | 'sum' | 'min' | 'max';
|
|
61
|
+
field?: string;
|
|
62
|
+
targetField: string;
|
|
50
63
|
}
|
|
51
64
|
export declare class BaseModel {
|
|
52
|
-
|
|
65
|
+
schema: Schema;
|
|
53
66
|
private adapter;
|
|
54
67
|
private collectionName;
|
|
55
68
|
constructor(schema: Schema, adapter: DatabaseAdapter);
|
|
56
69
|
create(data: any, includePrivateFields?: boolean): Promise<any>;
|
|
57
70
|
read(query: any, limit?: number, skip?: number, includePrivateFields?: boolean, options?: ReadOptions): Promise<any[]>;
|
|
58
|
-
update(id: string, data: any, includePrivateFields?: boolean): Promise<any>;
|
|
71
|
+
update(id: string, data: any, includePrivateFields?: boolean, oldDoc?: any): Promise<any>;
|
|
59
72
|
delete(id: string, includePrivateFields?: boolean): Promise<any>;
|
|
60
73
|
count(query: any, includePrivateFields?: boolean): Promise<number>;
|
|
61
74
|
findFirstRelatedIds(field: string, ids: string[]): Promise<Map<string, string>>;
|
package/dist/models/BaseModel.js
CHANGED
|
@@ -31,13 +31,13 @@ class BaseModel {
|
|
|
31
31
|
Events_1.events.emitEvent((0, Events_1.getModelEvent)(this.schema.modelName, 'read', 'after'), { ...payload, result });
|
|
32
32
|
return result;
|
|
33
33
|
}
|
|
34
|
-
async update(id, data, includePrivateFields) {
|
|
35
|
-
const payload = { modelName: this.schema.modelName, id, data };
|
|
34
|
+
async update(id, data, includePrivateFields, oldDoc) {
|
|
35
|
+
const payload = { modelName: this.schema.modelName, id, data, oldDoc };
|
|
36
36
|
Events_1.events.emitEvent(Events_1.Events.BEFORE_UPDATE, payload);
|
|
37
37
|
Events_1.events.emitEvent((0, Events_1.getModelEvent)(this.schema.modelName, 'update', 'before'), payload);
|
|
38
38
|
const result = await this.adapter.update(this.collectionName, id, data, this.schema, includePrivateFields);
|
|
39
|
-
Events_1.events.emitEvent(Events_1.Events.AFTER_UPDATE, { ...payload, result });
|
|
40
|
-
Events_1.events.emitEvent((0, Events_1.getModelEvent)(this.schema.modelName, 'update', 'after'), { ...payload, result });
|
|
39
|
+
Events_1.events.emitEvent(Events_1.Events.AFTER_UPDATE, { ...payload, result, oldDoc });
|
|
40
|
+
Events_1.events.emitEvent((0, Events_1.getModelEvent)(this.schema.modelName, 'update', 'after'), { ...payload, result, oldDoc });
|
|
41
41
|
return result;
|
|
42
42
|
}
|
|
43
43
|
async delete(id, includePrivateFields) {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.AggregateService = void 0;
|
|
7
|
+
const Events_1 = require("../utils/Events");
|
|
8
|
+
const Logger_1 = __importDefault(require("../utils/Logger"));
|
|
9
|
+
class AggregateService {
|
|
10
|
+
constructor(ctx) {
|
|
11
|
+
this.ctx = ctx;
|
|
12
|
+
this.init();
|
|
13
|
+
}
|
|
14
|
+
init() {
|
|
15
|
+
Events_1.events.on(Events_1.Events.AFTER_CREATE, (payload) => this.handleUpdate(payload.modelName, payload.result, 'create'));
|
|
16
|
+
Events_1.events.on(Events_1.Events.AFTER_UPDATE, (payload) => this.handleUpdate(payload.modelName, payload.result, 'update'));
|
|
17
|
+
Events_1.events.on(Events_1.Events.AFTER_DELETE, (payload) => this.handleUpdate(payload.modelName, payload.result, 'delete'));
|
|
18
|
+
Logger_1.default.info('AggregateService', 'Initialized and listening for CRUD events');
|
|
19
|
+
}
|
|
20
|
+
async handleUpdate(sourceModelName, doc, action) {
|
|
21
|
+
if (!doc)
|
|
22
|
+
return;
|
|
23
|
+
// Find SCHEMAS that have aggregates dependent on sourceModelName
|
|
24
|
+
const allSchemas = Object.values(this.ctx.liveSchemas);
|
|
25
|
+
for (const targetSchema of allSchemas) {
|
|
26
|
+
if (!targetSchema.aggregates)
|
|
27
|
+
continue;
|
|
28
|
+
const ags = targetSchema.aggregates.filter((a) => a.sourceModel.toLowerCase() === sourceModelName.toLowerCase());
|
|
29
|
+
if (ags.length === 0)
|
|
30
|
+
continue;
|
|
31
|
+
for (const ag of ags) {
|
|
32
|
+
const parentId = doc[ag.foreignField];
|
|
33
|
+
if (!parentId)
|
|
34
|
+
continue;
|
|
35
|
+
// Recalculate and update target document (Doctor)
|
|
36
|
+
await this.recalculate(targetSchema.modelName, parentId, ag);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async recalculate(targetModelName, targetId, ag) {
|
|
41
|
+
try {
|
|
42
|
+
const sourceModel = this.ctx.getModel(ag.sourceModel.toLowerCase());
|
|
43
|
+
const targetModel = this.ctx.getModel(targetModelName.toLowerCase());
|
|
44
|
+
const filter = { [ag.foreignField]: targetId };
|
|
45
|
+
let result = 0;
|
|
46
|
+
if (ag.op === 'count') {
|
|
47
|
+
result = await sourceModel.count(filter, true);
|
|
48
|
+
}
|
|
49
|
+
else if (ag.op === 'avg' || ag.op === 'sum') {
|
|
50
|
+
const adapter = this.ctx.dbAdapter;
|
|
51
|
+
if (adapter.dataSource.options.type === 'mongodb') {
|
|
52
|
+
const repo = adapter.dataSource.getMongoRepository(ag.sourceModel);
|
|
53
|
+
const aggregation = [
|
|
54
|
+
{ $match: adapter.transformQuery(filter, sourceModel.schema) },
|
|
55
|
+
{ $group: { _id: null, val: { [`$${ag.op}`]: `$${ag.field}` } } },
|
|
56
|
+
];
|
|
57
|
+
const res = await repo.aggregate(aggregation).toArray();
|
|
58
|
+
result = res[0]?.val || 0;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
// SQL
|
|
62
|
+
const repo = adapter.repositories.get(ag.sourceModel.toLowerCase());
|
|
63
|
+
const qb = repo.createQueryBuilder('entity');
|
|
64
|
+
qb.select(`${ag.op === 'avg' ? 'AVG' : 'SUM'}(entity.${ag.field})`, 'val');
|
|
65
|
+
qb.where(adapter.transformQuery(filter, sourceModel.schema));
|
|
66
|
+
const res = await qb.getRawOne();
|
|
67
|
+
result = parseFloat(res?.val || res?.VAL || 0);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Update target document (e.g. Doctor)
|
|
71
|
+
const updateData = {};
|
|
72
|
+
const parts = ag.targetField.split('.');
|
|
73
|
+
let curr = updateData;
|
|
74
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
75
|
+
curr[parts[i]] = {};
|
|
76
|
+
curr = curr[parts[i]];
|
|
77
|
+
}
|
|
78
|
+
curr[parts[parts.length - 1]] = result;
|
|
79
|
+
await targetModel.update(targetId, updateData, true);
|
|
80
|
+
Logger_1.default.debug('AggregateService', `Updated ${targetModelName}:${targetId} aggregate ${ag.targetField} to ${result}`);
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
Logger_1.default.error('AggregateService', `Failed to recalculate aggregate for ${targetModelName}:${targetId}`, err);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
exports.AggregateService = AggregateService;
|
package/dist/utils/Events.js
CHANGED
|
@@ -21,7 +21,7 @@ class NextMinEvents extends events_1.EventEmitter {
|
|
|
21
21
|
* Typed emit to provide better DX (Developer Experience)
|
|
22
22
|
*/
|
|
23
23
|
emitEvent(event, payload) {
|
|
24
|
-
Logger_1.default.
|
|
24
|
+
Logger_1.default.debug('NextMinEvents', `Emitting event: ${event}`);
|
|
25
25
|
this.emit(event, payload);
|
|
26
26
|
}
|
|
27
27
|
}
|
package/dist/utils/Logger.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export declare class Logger {
|
|
2
|
-
private isDevelopment;
|
|
2
|
+
private get isDevelopment();
|
|
3
3
|
constructor();
|
|
4
4
|
private getCallerInfo;
|
|
5
5
|
private formatMessage;
|
|
6
|
+
debug(label: string, ...messages: any[]): void;
|
|
6
7
|
log(label: string, ...messages: any[]): void;
|
|
7
8
|
info(label: string, ...messages: any[]): void;
|
|
8
9
|
warn(label: string, ...messages: any[]): void;
|
package/dist/utils/Logger.js
CHANGED
|
@@ -8,9 +8,11 @@ const kleur_1 = __importDefault(require("kleur"));
|
|
|
8
8
|
const util_1 = require("util");
|
|
9
9
|
const path_1 = __importDefault(require("path"));
|
|
10
10
|
class Logger {
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
get isDevelopment() {
|
|
12
|
+
return (process.env.APP_MODE?.toLowerCase() !== 'production' &&
|
|
13
|
+
process.env.NODE_ENV?.toLowerCase() !== 'production');
|
|
13
14
|
}
|
|
15
|
+
constructor() { }
|
|
14
16
|
getCallerInfo() {
|
|
15
17
|
const originalPrepareStackTrace = Error.prepareStackTrace;
|
|
16
18
|
Error.prepareStackTrace = (err, stack) => stack;
|
|
@@ -45,12 +47,21 @@ class Logger {
|
|
|
45
47
|
case 'ERROR':
|
|
46
48
|
formattedLabel = kleur_1.default.red(`[ERROR] ${label} (${callerInfo}):`);
|
|
47
49
|
break;
|
|
50
|
+
case 'DEBUG':
|
|
51
|
+
formattedLabel = kleur_1.default.magenta(`[DEBUG] ${label} (${callerInfo}):`);
|
|
52
|
+
break;
|
|
48
53
|
default:
|
|
49
54
|
formattedLabel = `[${level}] ${label} (${callerInfo}):`;
|
|
50
55
|
}
|
|
51
56
|
const formattedMessage = (0, util_1.format)(...messages);
|
|
52
57
|
return `${formattedLabel} ${formattedMessage}`;
|
|
53
58
|
}
|
|
59
|
+
debug(label, ...messages) {
|
|
60
|
+
if (this.isDevelopment) {
|
|
61
|
+
const formatted = this.formatMessage('DEBUG', label, messages);
|
|
62
|
+
console.debug(formatted);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
54
65
|
log(label, ...messages) {
|
|
55
66
|
if (this.isDevelopment) {
|
|
56
67
|
const formatted = this.formatMessage('LOG', label, messages);
|
|
@@ -70,10 +81,8 @@ class Logger {
|
|
|
70
81
|
}
|
|
71
82
|
}
|
|
72
83
|
error(label, ...messages) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
console.error(formatted);
|
|
76
|
-
}
|
|
84
|
+
const formatted = this.formatMessage('ERROR', label, messages);
|
|
85
|
+
console.error(formatted);
|
|
77
86
|
}
|
|
78
87
|
}
|
|
79
88
|
exports.Logger = Logger;
|
|
@@ -58,6 +58,10 @@ function validateSchema(obj) {
|
|
|
58
58
|
return missing;
|
|
59
59
|
}
|
|
60
60
|
class SchemaLoader {
|
|
61
|
+
get isDevelopment() {
|
|
62
|
+
return (process.env.APP_MODE?.toLowerCase() !== 'production' &&
|
|
63
|
+
process.env.NODE_ENV?.toLowerCase() !== 'production');
|
|
64
|
+
}
|
|
61
65
|
// Singleton instance
|
|
62
66
|
static getInstance() {
|
|
63
67
|
if (!SchemaLoader._instance) {
|
|
@@ -68,10 +72,8 @@ class SchemaLoader {
|
|
|
68
72
|
constructor() {
|
|
69
73
|
this.emitter = new events_1.EventEmitter();
|
|
70
74
|
this.schemas = {};
|
|
71
|
-
this.isDevelopment = false;
|
|
72
75
|
this.nonOverridableSchemas = new Set(['User', 'Role']);
|
|
73
76
|
this.watcher = null; // <-- TS-safe
|
|
74
|
-
this.isDevelopment = process.env.APP_MODE !== 'production';
|
|
75
77
|
const baseDir = process.cwd();
|
|
76
78
|
const basePackageDir = path_1.default.resolve(__dirname, '..');
|
|
77
79
|
this.packageSchemasDir = path_1.default.join(basePackageDir, 'schemas');
|