@airoom/nextmin-node 1.4.6 → 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.
Files changed (52) hide show
  1. package/README.md +48 -5
  2. package/dist/api/apiRouter.d.ts +10 -1
  3. package/dist/api/apiRouter.js +83 -22
  4. package/dist/api/router/mountBatchRoutes.d.ts +2 -0
  5. package/dist/api/router/mountBatchRoutes.js +90 -0
  6. package/dist/api/router/mountCrudRoutes.js +218 -223
  7. package/dist/api/router/mountFindRoutes.js +2 -49
  8. package/dist/api/router/mountSearchRoutes.js +10 -52
  9. package/dist/api/router/mountSearchRoutes_extended.js +7 -48
  10. package/dist/api/router/setupFileRoutes.js +41 -0
  11. package/dist/api/router/utils.d.ts +2 -0
  12. package/dist/api/router/utils.js +37 -8
  13. package/dist/cli.d.ts +1 -0
  14. package/dist/cli.js +83 -0
  15. package/dist/database/DatabaseAdapter.d.ts +8 -0
  16. package/dist/database/NMAdapter.d.ts +46 -0
  17. package/dist/database/NMAdapter.js +1243 -0
  18. package/dist/database/QueryEngine.d.ts +14 -0
  19. package/dist/database/QueryEngine.js +215 -0
  20. package/dist/database/utils.d.ts +2 -0
  21. package/dist/database/utils.js +21 -0
  22. package/dist/files/FileStorageAdapter.d.ts +5 -0
  23. package/dist/files/LocalFileStorageAdapter.d.ts +5 -0
  24. package/dist/files/LocalFileStorageAdapter.js +14 -0
  25. package/dist/files/S3FileStorageAdapter.d.ts +5 -0
  26. package/dist/files/S3FileStorageAdapter.js +14 -0
  27. package/dist/index.d.ts +4 -1
  28. package/dist/index.js +11 -5
  29. package/dist/models/BaseModel.d.ts +31 -2
  30. package/dist/models/BaseModel.js +33 -5
  31. package/dist/policy/authorize.js +95 -38
  32. package/dist/schemas/Users.json +66 -30
  33. package/dist/services/AggregateService.d.ts +8 -0
  34. package/dist/services/AggregateService.js +87 -0
  35. package/dist/services/RealtimeService.d.ts +20 -0
  36. package/dist/services/RealtimeService.js +93 -0
  37. package/dist/services/SchemaService.d.ts +3 -0
  38. package/dist/services/SchemaService.js +6 -2
  39. package/dist/utils/DefaultDataInitializer.js +10 -2
  40. package/dist/utils/Events.d.ts +34 -0
  41. package/dist/utils/Events.js +55 -0
  42. package/dist/utils/Logger.d.ts +2 -1
  43. package/dist/utils/Logger.js +20 -9
  44. package/dist/utils/QueryCache.d.ts +16 -0
  45. package/dist/utils/QueryCache.js +106 -0
  46. package/dist/utils/SchemaLoader.d.ts +6 -1
  47. package/dist/utils/SchemaLoader.js +49 -5
  48. package/package.json +19 -4
  49. package/dist/database/InMemoryAdapter.d.ts +0 -15
  50. package/dist/database/InMemoryAdapter.js +0 -71
  51. package/dist/database/MongoAdapter.d.ts +0 -52
  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: MongoDB (with index sync) and in‑memory for tests
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
- MongoAdapter,
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 MongoAdapter(process.env.MONGO_URL!, process.env.MONGO_DB!);
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
- const router = createNextMinRouter({ dbAdapter: db, server, fileStorageAdapter: files });
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
@@ -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,16 +18,19 @@ 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;
23
28
  private liveSchemas;
24
29
  private notFoundHandler?;
25
30
  private fileStorage?;
31
+ private realtimeService?;
26
32
  private fileRoutesMounted;
33
+ private aggregateService?;
27
34
  constructor(options: APIRouterOptions);
28
35
  getRouter(): express.Router;
29
36
  private wireSchemaHotReload;
@@ -32,9 +39,11 @@ export declare class APIRouter {
32
39
  private setLiveSchemas;
33
40
  private rebuildModels;
34
41
  private mountSchemasEndpointOnce;
42
+ private mountCleanupEndpointOnce;
35
43
  private mountRoutes;
36
44
  private mountFindRoutes;
37
45
  private mountSearchRoutes;
46
+ private mountBatchRoutes;
38
47
  private mountFileRoutes;
39
48
  private createCtx;
40
49
  private getSchema;
@@ -9,19 +9,26 @@ 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 SchemaService_1 = require("../services/SchemaService");
13
+ const RealtimeService_1 = require("../services/RealtimeService");
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();
@@ -101,6 +108,17 @@ class APIRouter {
101
108
  return n ? n.toLowerCase() : null;
102
109
  }
103
110
  }
111
+ // Handle numeric IDs (common in SQL databases like SQLite)
112
+ if (typeof value === 'number' && rolesModel) {
113
+ try {
114
+ const docs = await rolesModel.read({ id: value }, 1, 0, true);
115
+ const n = docs?.[0]?.name;
116
+ return typeof n === 'string' ? n.toLowerCase() : null;
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ }
104
122
  return null;
105
123
  };
106
124
  // ---------- auth middlewares ----------
@@ -171,19 +189,18 @@ class APIRouter {
171
189
  }
172
190
  return missing;
173
191
  };
174
- this.isDevelopment = process.env.APP_MODE !== 'production';
175
192
  this.router = express_1.default.Router();
176
193
  this.fileStorage = options.fileStorageAdapter;
177
- this.dbAdapter = options.dbAdapter || new InMemoryAdapter_1.InMemoryAdapter();
194
+ this.dbAdapter = options.dbAdapter;
178
195
  this.jwtSecret = process.env.JWT_SECRET || 'default_jwt_secret';
179
- if (this.isDevelopment && options.server) {
180
- (0, SchemaService_1.startSchemaService)(options.server, {
196
+ if (options.server) {
197
+ this.realtimeService = new RealtimeService_1.RealtimeService(options.server, {
181
198
  getApiKey: () => this.trustedApiKey,
182
199
  });
183
200
  options.server.on('listening', () => {
184
201
  // @ts-ignore
185
202
  const addr = options.server.address();
186
- Logger_1.default.info('SchemaService', `[schema-service] started at /__nextmin__/schema ns /schema on ${typeof addr === 'string' ? addr : `${addr?.address}:${addr?.port}`}`);
203
+ Logger_1.default.info('RealtimeService', `Started at /__nextmin__/realtime ns /realtime on ${typeof addr === 'string' ? addr : `${addr?.address}:${addr?.port}`}`);
187
204
  });
188
205
  }
189
206
  this.schemaLoader =
@@ -196,9 +213,11 @@ class APIRouter {
196
213
  }
197
214
  this.rebuildModels(Object.values(initialSchemas));
198
215
  this.mountSchemasEndpointOnce();
216
+ this.mountCleanupEndpointOnce();
199
217
  this.mountRoutes(initialSchemas);
200
218
  this.mountFindRoutes();
201
219
  this.mountSearchRoutes();
220
+ this.mountBatchRoutes();
202
221
  this.mountFileRoutes();
203
222
  await this.syncAllIndexes(initialSchemas);
204
223
  const initializer = new DefaultDataInitializer_1.DefaultDataInitializer(this.dbAdapter, this.models);
@@ -206,11 +225,13 @@ class APIRouter {
206
225
  await initializer.initialize();
207
226
  this.trustedApiKey = initializer.getApiKey() || '';
208
227
  Logger_1.default.info('APIRouter', `Trusted API key set: ${this.trustedApiKey ? '[hidden]' : 'none'}`);
228
+ Events_1.events.emitEvent(Events_1.Events.SERVER_START, { trustedApiKey: this.trustedApiKey });
209
229
  }
210
230
  catch (err) {
211
231
  Logger_1.default.error('APIRouter', 'Failed to initialize default data', err);
212
232
  }
213
233
  this.setupNotFoundMiddleware();
234
+ this.aggregateService = new AggregateService_1.AggregateService(this.createCtx());
214
235
  this.wireSchemaHotReload();
215
236
  };
216
237
  if (typeof this.dbAdapter.registerSchemas === 'function') {
@@ -260,6 +281,7 @@ class APIRouter {
260
281
  this.mountRoutes(newSchemas);
261
282
  await this.syncAllIndexes(newSchemas);
262
283
  Logger_1.default.info('APIRouter', `Schemas reloaded (added/updated: ${Object.keys(newSchemas).length}, removed: ${removed.join(', ') || 'none'})`);
284
+ Events_1.events.emitEvent(Events_1.Events.SCHEMA_UPDATE, { schemas: newSchemas, removed });
263
285
  }
264
286
  catch (err) {
265
287
  Logger_1.default.error('APIRouter', 'Failed to refresh after schemasChanged', err);
@@ -310,15 +332,38 @@ class APIRouter {
310
332
  return;
311
333
  this.schemasRouteRegistered = true;
312
334
  this.router.get('/_schemas', this.optionalAuthMiddleware, async (req, res) => {
313
- const role = await this.normalizeRoleName(this.getUserRoleFromReq(req));
314
- // Typically admin/superadmin can see clinical/private fields in schema
315
- const showPrivate = role === 'admin' || role === 'superadmin';
335
+ // Typically admin/superadmin can see clinical/private fields in schema.
336
+ // We now always include them in metadata so the frontend can decide visibility.
316
337
  res.json({
317
338
  success: true,
318
- data: this.schemaLoader.getPublicSchemaList(showPrivate),
339
+ data: this.schemaLoader.getPublicSchemaList(true),
319
340
  });
320
341
  });
321
342
  }
343
+ mountCleanupEndpointOnce() {
344
+ this.router.post('/_cleanup', this.optionalAuthMiddleware, async (req, res) => {
345
+ try {
346
+ if (typeof this.dbAdapter.cleanupUnusedFields !== 'function') {
347
+ return res.status(501).json({
348
+ success: false,
349
+ message: 'Database adapter does not support cleanup',
350
+ });
351
+ }
352
+ const report = await this.dbAdapter.cleanupUnusedFields(this.schemaLoader.getSchemas());
353
+ res.json({
354
+ success: true,
355
+ data: report,
356
+ });
357
+ }
358
+ catch (err) {
359
+ Logger_1.default.error('APIRouter', 'Cleanup failed', err);
360
+ res.status(500).json({
361
+ success: false,
362
+ message: err.message || 'Cleanup failed',
363
+ });
364
+ }
365
+ });
366
+ }
322
367
  mountRoutes(schemas) {
323
368
  if (!this.authRoutesInitialized && this.liveSchemas['users']) {
324
369
  (0, setupAuthRoutes_1.setupAuthRoutes)(this.createCtx());
@@ -347,6 +392,13 @@ class APIRouter {
347
392
  (0, mountSearchRoutes_1.mountSearchRoutes)(this.createCtx());
348
393
  this.ensureNotFoundLast();
349
394
  }
395
+ mountBatchRoutes() {
396
+ if (this.batchRoutesMounted)
397
+ return;
398
+ this.batchRoutesMounted = true;
399
+ (0, mountBatchRoutes_1.mountBatchRoutes)(this.createCtx());
400
+ this.ensureNotFoundLast();
401
+ }
350
402
  mountFileRoutes() {
351
403
  if (this.fileRoutesMounted)
352
404
  return;
@@ -412,10 +464,13 @@ class APIRouter {
412
464
  return;
413
465
  }
414
466
  if (error?.code === 11000) {
415
- const field = Object.keys(error.keyPattern || {})[0];
416
- res
417
- .status(400)
418
- .json({ error: true, message: `Duplicate value for field: ${field}` });
467
+ const field = Object.keys(error.keyPattern || error.keyValue || {}).find((k) => k !== '_id') ||
468
+ Object.keys(error.keyPattern || error.keyValue || {})[0] ||
469
+ 'unknown';
470
+ res.status(400).json({
471
+ error: true,
472
+ message: `Duplicate value for field: ${field}. ${error.message || ''}`,
473
+ });
419
474
  }
420
475
  else {
421
476
  res.status(400).json({ error: true, message: error?.message || 'Error' });
@@ -447,14 +502,20 @@ class APIRouter {
447
502
  .map(([key]) => key);
448
503
  const conflictingFields = [];
449
504
  for (const field of uniqueFields) {
450
- if (data[field] !== undefined) {
451
- const query = { [field]: data[field] };
452
- if (excludeId)
453
- query.id = { $ne: excludeId };
454
- const existingRecords = await this.getModel(schema.modelName.toLowerCase()).read(query);
455
- if (existingRecords.length > 0)
456
- conflictingFields.push(field);
505
+ const val = data[field];
506
+ const attr = schema.attributes?.[field];
507
+ const isRequired = Array.isArray(attr) ? attr[0]?.required : attr?.required;
508
+ // Sparse uniqueness: skip checking if value is empty/null AND the field is not required
509
+ if (val === undefined || val === null || (typeof val === 'string' && val.trim() === '')) {
510
+ if (!isRequired)
511
+ continue;
457
512
  }
513
+ const query = { [field]: val };
514
+ if (excludeId)
515
+ query.id = { $ne: excludeId };
516
+ const existingRecords = await this.getModel(schema.modelName.toLowerCase()).read(query);
517
+ if (existingRecords.length > 0)
518
+ conflictingFields.push(field);
458
519
  }
459
520
  return conflictingFields.length > 0 ? conflictingFields : null;
460
521
  }
@@ -0,0 +1,2 @@
1
+ import type { RouterCtx } from './ctx';
2
+ export declare function mountBatchRoutes(ctx: RouterCtx): void;
@@ -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
+ }