@airoom/nextmin-node 1.4.5 → 2.0.1
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/README.md +48 -5
- package/dist/api/apiRouter.d.ts +2 -0
- package/dist/api/apiRouter.js +68 -19
- package/dist/api/router/mountCrudRoutes.js +209 -221
- package/dist/api/router/mountFindRoutes.js +2 -49
- package/dist/api/router/mountSearchRoutes.js +10 -52
- package/dist/api/router/mountSearchRoutes_extended.js +7 -48
- package/dist/api/router/setupAuthRoutes.js +6 -2
- package/dist/api/router/utils.js +20 -7
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +83 -0
- package/dist/database/DatabaseAdapter.d.ts +7 -0
- package/dist/database/NMAdapter.d.ts +41 -0
- package/dist/database/NMAdapter.js +979 -0
- package/dist/database/QueryEngine.d.ts +14 -0
- package/dist/database/QueryEngine.js +215 -0
- package/dist/database/utils.d.ts +2 -0
- package/dist/database/utils.js +21 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +11 -5
- package/dist/models/BaseModel.d.ts +16 -0
- package/dist/models/BaseModel.js +32 -4
- package/dist/policy/authorize.js +118 -43
- package/dist/schemas/Users.json +66 -30
- package/dist/services/RealtimeService.d.ts +20 -0
- package/dist/services/RealtimeService.js +93 -0
- package/dist/services/SchemaService.d.ts +3 -0
- package/dist/services/SchemaService.js +9 -5
- package/dist/utils/DefaultDataInitializer.js +10 -2
- package/dist/utils/Events.d.ts +34 -0
- package/dist/utils/Events.js +55 -0
- package/dist/utils/Logger.js +12 -10
- package/dist/utils/QueryCache.d.ts +16 -0
- package/dist/utils/QueryCache.js +106 -0
- package/dist/utils/SchemaLoader.d.ts +7 -2
- package/dist/utils/SchemaLoader.js +58 -18
- package/package.json +19 -4
- package/dist/database/InMemoryAdapter.d.ts +0 -15
- package/dist/database/InMemoryAdapter.js +0 -71
- package/dist/database/MongoAdapter.d.ts +0 -52
- package/dist/database/MongoAdapter.js +0 -410
package/README.md
CHANGED
|
@@ -12,13 +12,15 @@ Read the full documentation at: https://nextmin.gscodes.dev/
|
|
|
12
12
|
## Highlights
|
|
13
13
|
|
|
14
14
|
- Express router factory: mount a complete REST API in a few lines
|
|
15
|
+
- **Native Realtime Support**: Built-in Socket.io integration for instant schema updates and data synchronization
|
|
16
|
+
- **Event-Driven Architecture**: Lifecycle hooks (`before:create`, `after:update`, etc.) for custom business logic
|
|
15
17
|
- Auth built in: register, login, me, change‑password, forgot‑password
|
|
16
18
|
- CRUD per model with read masks, write restrictions, and role/owner policies
|
|
17
19
|
- Advanced list endpoint: filter, multi‑field search, date ranges, multi‑field sort, paginate
|
|
18
20
|
- Relationship endpoints: forward and reverse lookups without autopopulate
|
|
19
21
|
- Schemas hot‑reload during development; automatic model wiring
|
|
20
22
|
- File uploads via pluggable storage (e.g., S3/MinIO); delete by key
|
|
21
|
-
- Database adapters:
|
|
23
|
+
- Database adapters: **NMAdapter (Recommended)** supports SQL (Postgres/SQLite/MySQL) and MongoDB via TypeORM. The standalone `MongoAdapter` and `InMemoryAdapter` are now deprecated.
|
|
22
24
|
- Emits a trusted API key stored in your Settings model for client access
|
|
23
25
|
|
|
24
26
|
## Installation
|
|
@@ -42,7 +44,7 @@ import express from 'express';
|
|
|
42
44
|
import http from 'http';
|
|
43
45
|
import {
|
|
44
46
|
createNextMinRouter,
|
|
45
|
-
|
|
47
|
+
NMAdapter,
|
|
46
48
|
S3FileStorageAdapter,
|
|
47
49
|
} from '@airoom/nextmin-node';
|
|
48
50
|
import cors from 'cors';
|
|
@@ -55,8 +57,12 @@ async function start() {
|
|
|
55
57
|
|
|
56
58
|
app.use(cors());
|
|
57
59
|
|
|
58
|
-
// 1) Database
|
|
59
|
-
const db = new
|
|
60
|
+
// 1) Database (NMAdapter supports SQL and MongoDB)
|
|
61
|
+
const db = new NMAdapter({
|
|
62
|
+
type: 'postgres', // or 'mongodb', 'sqlite', 'mysql'
|
|
63
|
+
url: process.env.DATABASE_URL,
|
|
64
|
+
synchronize: true, // typical for development
|
|
65
|
+
});
|
|
60
66
|
await db.connect();
|
|
61
67
|
|
|
62
68
|
// 2) Optional: file storage adapter (S3/MinIO)
|
|
@@ -77,7 +83,12 @@ async function start() {
|
|
|
77
83
|
});
|
|
78
84
|
|
|
79
85
|
// 3) Mount NextMin REST router
|
|
80
|
-
|
|
86
|
+
// Pass the 'server' instance to enable native Realtime/WebSockets
|
|
87
|
+
const router = createNextMinRouter({
|
|
88
|
+
dbAdapter: db,
|
|
89
|
+
server: server, // REQUIRED for realtime
|
|
90
|
+
fileStorageAdapter: files
|
|
91
|
+
});
|
|
81
92
|
app.use('/rest', router);
|
|
82
93
|
|
|
83
94
|
// 4) Listen with the same server instance
|
|
@@ -115,6 +126,38 @@ Base: `/rest`
|
|
|
115
126
|
- Upload: `POST /files` (multipart form, fields named `file`)
|
|
116
127
|
- Delete: `DELETE /files/:key(*)`
|
|
117
128
|
|
|
129
|
+
## Event System (Hooks)
|
|
130
|
+
|
|
131
|
+
NextMin provides a powerful event-driven system to inject custom logic before or after database operations.
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { events, Events, getModelEvent } from '@airoom/nextmin-node';
|
|
135
|
+
|
|
136
|
+
// Global hook: Run logic after any document is created
|
|
137
|
+
events.on(Events.AFTER_CREATE, ({ modelName, data }) => {
|
|
138
|
+
console.log(`Document created in ${modelName}:`, data.id);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Model-specific hook: Send email after a User registers
|
|
142
|
+
events.on(getModelEvent('User', 'create', 'after'), ({ data }) => {
|
|
143
|
+
sendWelcomeEmail(data.email);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Validation hook: Prevent deletion if certain conditions aren't met
|
|
147
|
+
events.on(getModelEvent('Post', 'delete', 'before'), ({ id }) => {
|
|
148
|
+
if (isSystemProtected(id)) {
|
|
149
|
+
throw new Error("This post cannot be deleted.");
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Available Events
|
|
155
|
+
- `before:doc:create`, `after:doc:create`
|
|
156
|
+
- `before:doc:update`, `after:doc:update`
|
|
157
|
+
- `before:doc:delete`, `after:doc:delete`
|
|
158
|
+
- `before:doc:read`, `after:doc:read`
|
|
159
|
+
- `auth:login`, `auth:signup`, `schema:update`
|
|
160
|
+
|
|
118
161
|
See full examples in documentation and the `examples/node` app inside this monorepo.
|
|
119
162
|
|
|
120
163
|
## Headers and auth
|
package/dist/api/apiRouter.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export declare class APIRouter {
|
|
|
23
23
|
private liveSchemas;
|
|
24
24
|
private notFoundHandler?;
|
|
25
25
|
private fileStorage?;
|
|
26
|
+
private realtimeService?;
|
|
26
27
|
private fileRoutesMounted;
|
|
27
28
|
constructor(options: APIRouterOptions);
|
|
28
29
|
getRouter(): express.Router;
|
|
@@ -32,6 +33,7 @@ export declare class APIRouter {
|
|
|
32
33
|
private setLiveSchemas;
|
|
33
34
|
private rebuildModels;
|
|
34
35
|
private mountSchemasEndpointOnce;
|
|
36
|
+
private mountCleanupEndpointOnce;
|
|
35
37
|
private mountRoutes;
|
|
36
38
|
private mountFindRoutes;
|
|
37
39
|
private mountSearchRoutes;
|
package/dist/api/apiRouter.js
CHANGED
|
@@ -9,9 +9,9 @@ const BaseModel_1 = require("../models/BaseModel");
|
|
|
9
9
|
const Logger_1 = __importDefault(require("../utils/Logger"));
|
|
10
10
|
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
|
11
11
|
const SchemaLoader_1 = require("../utils/SchemaLoader");
|
|
12
|
-
const InMemoryAdapter_1 = require("../database/InMemoryAdapter");
|
|
13
12
|
const DefaultDataInitializer_1 = require("../utils/DefaultDataInitializer");
|
|
14
|
-
const
|
|
13
|
+
const RealtimeService_1 = require("../services/RealtimeService");
|
|
14
|
+
const Events_1 = require("../utils/Events");
|
|
15
15
|
const setupFileRoutes_1 = require("./router/setupFileRoutes");
|
|
16
16
|
const setupAuthRoutes_1 = require("./router/setupAuthRoutes");
|
|
17
17
|
const mountCrudRoutes_1 = require("./router/mountCrudRoutes");
|
|
@@ -101,6 +101,17 @@ class APIRouter {
|
|
|
101
101
|
return n ? n.toLowerCase() : null;
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
|
+
// Handle numeric IDs (common in SQL databases like SQLite)
|
|
105
|
+
if (typeof value === 'number' && rolesModel) {
|
|
106
|
+
try {
|
|
107
|
+
const docs = await rolesModel.read({ id: value }, 1, 0, true);
|
|
108
|
+
const n = docs?.[0]?.name;
|
|
109
|
+
return typeof n === 'string' ? n.toLowerCase() : null;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
104
115
|
return null;
|
|
105
116
|
};
|
|
106
117
|
// ---------- auth middlewares ----------
|
|
@@ -174,16 +185,16 @@ class APIRouter {
|
|
|
174
185
|
this.isDevelopment = process.env.APP_MODE !== 'production';
|
|
175
186
|
this.router = express_1.default.Router();
|
|
176
187
|
this.fileStorage = options.fileStorageAdapter;
|
|
177
|
-
this.dbAdapter = options.dbAdapter
|
|
188
|
+
this.dbAdapter = options.dbAdapter;
|
|
178
189
|
this.jwtSecret = process.env.JWT_SECRET || 'default_jwt_secret';
|
|
179
|
-
if (
|
|
180
|
-
|
|
190
|
+
if (options.server) {
|
|
191
|
+
this.realtimeService = new RealtimeService_1.RealtimeService(options.server, {
|
|
181
192
|
getApiKey: () => this.trustedApiKey,
|
|
182
193
|
});
|
|
183
194
|
options.server.on('listening', () => {
|
|
184
195
|
// @ts-ignore
|
|
185
196
|
const addr = options.server.address();
|
|
186
|
-
Logger_1.default.info('
|
|
197
|
+
Logger_1.default.info('RealtimeService', `Started at /__nextmin__/realtime ns /realtime on ${typeof addr === 'string' ? addr : `${addr?.address}:${addr?.port}`}`);
|
|
187
198
|
});
|
|
188
199
|
}
|
|
189
200
|
this.schemaLoader =
|
|
@@ -196,6 +207,7 @@ class APIRouter {
|
|
|
196
207
|
}
|
|
197
208
|
this.rebuildModels(Object.values(initialSchemas));
|
|
198
209
|
this.mountSchemasEndpointOnce();
|
|
210
|
+
this.mountCleanupEndpointOnce();
|
|
199
211
|
this.mountRoutes(initialSchemas);
|
|
200
212
|
this.mountFindRoutes();
|
|
201
213
|
this.mountSearchRoutes();
|
|
@@ -206,6 +218,7 @@ class APIRouter {
|
|
|
206
218
|
await initializer.initialize();
|
|
207
219
|
this.trustedApiKey = initializer.getApiKey() || '';
|
|
208
220
|
Logger_1.default.info('APIRouter', `Trusted API key set: ${this.trustedApiKey ? '[hidden]' : 'none'}`);
|
|
221
|
+
Events_1.events.emitEvent(Events_1.Events.SERVER_START, { trustedApiKey: this.trustedApiKey });
|
|
209
222
|
}
|
|
210
223
|
catch (err) {
|
|
211
224
|
Logger_1.default.error('APIRouter', 'Failed to initialize default data', err);
|
|
@@ -260,6 +273,7 @@ class APIRouter {
|
|
|
260
273
|
this.mountRoutes(newSchemas);
|
|
261
274
|
await this.syncAllIndexes(newSchemas);
|
|
262
275
|
Logger_1.default.info('APIRouter', `Schemas reloaded (added/updated: ${Object.keys(newSchemas).length}, removed: ${removed.join(', ') || 'none'})`);
|
|
276
|
+
Events_1.events.emitEvent(Events_1.Events.SCHEMA_UPDATE, { schemas: newSchemas, removed });
|
|
263
277
|
}
|
|
264
278
|
catch (err) {
|
|
265
279
|
Logger_1.default.error('APIRouter', 'Failed to refresh after schemasChanged', err);
|
|
@@ -309,13 +323,39 @@ class APIRouter {
|
|
|
309
323
|
if (this.schemasRouteRegistered)
|
|
310
324
|
return;
|
|
311
325
|
this.schemasRouteRegistered = true;
|
|
312
|
-
this.router.get('/_schemas', this.
|
|
326
|
+
this.router.get('/_schemas', this.optionalAuthMiddleware, async (req, res) => {
|
|
327
|
+
// Typically admin/superadmin can see clinical/private fields in schema.
|
|
328
|
+
// We now always include them in metadata so the frontend can decide visibility.
|
|
313
329
|
res.json({
|
|
314
330
|
success: true,
|
|
315
|
-
data: this.schemaLoader.getPublicSchemaList(),
|
|
331
|
+
data: this.schemaLoader.getPublicSchemaList(true),
|
|
316
332
|
});
|
|
317
333
|
});
|
|
318
334
|
}
|
|
335
|
+
mountCleanupEndpointOnce() {
|
|
336
|
+
this.router.post('/_cleanup', this.optionalAuthMiddleware, async (req, res) => {
|
|
337
|
+
try {
|
|
338
|
+
if (typeof this.dbAdapter.cleanupUnusedFields !== 'function') {
|
|
339
|
+
return res.status(501).json({
|
|
340
|
+
success: false,
|
|
341
|
+
message: 'Database adapter does not support cleanup',
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
const report = await this.dbAdapter.cleanupUnusedFields(this.schemaLoader.getSchemas());
|
|
345
|
+
res.json({
|
|
346
|
+
success: true,
|
|
347
|
+
data: report,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
Logger_1.default.error('APIRouter', 'Cleanup failed', err);
|
|
352
|
+
res.status(500).json({
|
|
353
|
+
success: false,
|
|
354
|
+
message: err.message || 'Cleanup failed',
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
}
|
|
319
359
|
mountRoutes(schemas) {
|
|
320
360
|
if (!this.authRoutesInitialized && this.liveSchemas['users']) {
|
|
321
361
|
(0, setupAuthRoutes_1.setupAuthRoutes)(this.createCtx());
|
|
@@ -409,10 +449,13 @@ class APIRouter {
|
|
|
409
449
|
return;
|
|
410
450
|
}
|
|
411
451
|
if (error?.code === 11000) {
|
|
412
|
-
const field = Object.keys(error.keyPattern || {})
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
452
|
+
const field = Object.keys(error.keyPattern || error.keyValue || {}).find((k) => k !== '_id') ||
|
|
453
|
+
Object.keys(error.keyPattern || error.keyValue || {})[0] ||
|
|
454
|
+
'unknown';
|
|
455
|
+
res.status(400).json({
|
|
456
|
+
error: true,
|
|
457
|
+
message: `Duplicate value for field: ${field}. ${error.message || ''}`,
|
|
458
|
+
});
|
|
416
459
|
}
|
|
417
460
|
else {
|
|
418
461
|
res.status(400).json({ error: true, message: error?.message || 'Error' });
|
|
@@ -444,14 +487,20 @@ class APIRouter {
|
|
|
444
487
|
.map(([key]) => key);
|
|
445
488
|
const conflictingFields = [];
|
|
446
489
|
for (const field of uniqueFields) {
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
if (
|
|
453
|
-
|
|
490
|
+
const val = data[field];
|
|
491
|
+
const attr = schema.attributes?.[field];
|
|
492
|
+
const isRequired = Array.isArray(attr) ? attr[0]?.required : attr?.required;
|
|
493
|
+
// Sparse uniqueness: skip checking if value is empty/null AND the field is not required
|
|
494
|
+
if (val === undefined || val === null || (typeof val === 'string' && val.trim() === '')) {
|
|
495
|
+
if (!isRequired)
|
|
496
|
+
continue;
|
|
454
497
|
}
|
|
498
|
+
const query = { [field]: val };
|
|
499
|
+
if (excludeId)
|
|
500
|
+
query.id = { $ne: excludeId };
|
|
501
|
+
const existingRecords = await this.getModel(schema.modelName.toLowerCase()).read(query);
|
|
502
|
+
if (existingRecords.length > 0)
|
|
503
|
+
conflictingFields.push(field);
|
|
455
504
|
}
|
|
456
505
|
return conflictingFields.length > 0 ? conflictingFields : null;
|
|
457
506
|
}
|