@airoom/nextmin-node 1.4.6 → 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.
Files changed (40) hide show
  1. package/README.md +48 -5
  2. package/dist/api/apiRouter.d.ts +2 -0
  3. package/dist/api/apiRouter.js +67 -21
  4. package/dist/api/router/mountCrudRoutes.js +207 -220
  5. package/dist/api/router/mountFindRoutes.js +2 -49
  6. package/dist/api/router/mountSearchRoutes.js +10 -52
  7. package/dist/api/router/mountSearchRoutes_extended.js +7 -48
  8. package/dist/api/router/utils.js +20 -7
  9. package/dist/cli.d.ts +1 -0
  10. package/dist/cli.js +83 -0
  11. package/dist/database/DatabaseAdapter.d.ts +7 -0
  12. package/dist/database/NMAdapter.d.ts +41 -0
  13. package/dist/database/NMAdapter.js +979 -0
  14. package/dist/database/QueryEngine.d.ts +14 -0
  15. package/dist/database/QueryEngine.js +215 -0
  16. package/dist/database/utils.d.ts +2 -0
  17. package/dist/database/utils.js +21 -0
  18. package/dist/index.d.ts +4 -1
  19. package/dist/index.js +11 -5
  20. package/dist/models/BaseModel.d.ts +16 -0
  21. package/dist/models/BaseModel.js +32 -4
  22. package/dist/policy/authorize.js +95 -38
  23. package/dist/schemas/Users.json +66 -30
  24. package/dist/services/RealtimeService.d.ts +20 -0
  25. package/dist/services/RealtimeService.js +93 -0
  26. package/dist/services/SchemaService.d.ts +3 -0
  27. package/dist/services/SchemaService.js +6 -2
  28. package/dist/utils/DefaultDataInitializer.js +10 -2
  29. package/dist/utils/Events.d.ts +34 -0
  30. package/dist/utils/Events.js +55 -0
  31. package/dist/utils/Logger.js +12 -10
  32. package/dist/utils/QueryCache.d.ts +16 -0
  33. package/dist/utils/QueryCache.js +106 -0
  34. package/dist/utils/SchemaLoader.d.ts +5 -0
  35. package/dist/utils/SchemaLoader.js +45 -3
  36. package/package.json +19 -4
  37. package/dist/database/InMemoryAdapter.d.ts +0 -15
  38. package/dist/database/InMemoryAdapter.js +0 -71
  39. package/dist/database/MongoAdapter.d.ts +0 -52
  40. 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
@@ -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;
@@ -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 SchemaService_1 = require("../services/SchemaService");
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 || new InMemoryAdapter_1.InMemoryAdapter();
188
+ this.dbAdapter = options.dbAdapter;
178
189
  this.jwtSecret = process.env.JWT_SECRET || 'default_jwt_secret';
179
- if (this.isDevelopment && options.server) {
180
- (0, SchemaService_1.startSchemaService)(options.server, {
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('SchemaService', `[schema-service] started at /__nextmin__/schema ns /schema on ${typeof addr === 'string' ? addr : `${addr?.address}:${addr?.port}`}`);
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);
@@ -310,15 +324,38 @@ class APIRouter {
310
324
  return;
311
325
  this.schemasRouteRegistered = true;
312
326
  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';
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.
316
329
  res.json({
317
330
  success: true,
318
- data: this.schemaLoader.getPublicSchemaList(showPrivate),
331
+ data: this.schemaLoader.getPublicSchemaList(true),
319
332
  });
320
333
  });
321
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
+ }
322
359
  mountRoutes(schemas) {
323
360
  if (!this.authRoutesInitialized && this.liveSchemas['users']) {
324
361
  (0, setupAuthRoutes_1.setupAuthRoutes)(this.createCtx());
@@ -412,10 +449,13 @@ class APIRouter {
412
449
  return;
413
450
  }
414
451
  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}` });
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
+ });
419
459
  }
420
460
  else {
421
461
  res.status(400).json({ error: true, message: error?.message || 'Error' });
@@ -447,14 +487,20 @@ class APIRouter {
447
487
  .map(([key]) => key);
448
488
  const conflictingFields = [];
449
489
  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);
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;
457
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);
458
504
  }
459
505
  return conflictingFields.length > 0 ? conflictingFields : null;
460
506
  }