@nitronjs/framework 0.2.23 → 0.2.26

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/cli/njs.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  const COLORS = {
4
4
  reset: "\x1b[0m",
@@ -183,7 +183,7 @@ async function run() {
183
183
  if (exitCode !== null) {
184
184
  const { default: checkForUpdates } = await import("../lib/Console/UpdateChecker.js");
185
185
  await checkForUpdates();
186
- process.exitCode = exitCode;
186
+ process.exit(exitCode);
187
187
  }
188
188
  } catch (error) {
189
189
  console.error(`${COLORS.red}Error: ${error.message}${COLORS.reset}`);
@@ -78,34 +78,28 @@ function stripAnsi(str) {
78
78
  }
79
79
 
80
80
  function printUpdateNotice(current, latest) {
81
- const title = `${C.yellow}${C.bold}Update available${C.reset}`;
82
- const version = `${C.dim}${current}${C.reset} ${C.cyan}\u2192${C.reset} ${C.green}${C.bold}${latest}${C.reset}`;
83
- const cmd = `${C.cyan}npm update @nitronjs/framework --save${C.reset}`;
81
+ const boxWidth = 70;
82
+ const border = C.yellow;
83
+ const pad = (content, length) => content + " ".repeat(Math.max(0, length - stripAnsi(content).length));
84
84
 
85
85
  const lines = [
86
+ `${border}${C.bold}!${C.reset} ${C.bold}Update available${C.reset}`,
86
87
  "",
87
- ` ${title}`,
88
- ` ${version}`,
88
+ `${C.bold}Current:${C.reset} ${current}`,
89
+ `${C.bold}Latest:${C.reset} ${C.green}${C.bold}${latest}${C.reset}`,
89
90
  "",
90
- ` ${cmd}`,
91
- ""
91
+ `${C.bold}Run:${C.reset} ${C.cyan}npm update @nitronjs/framework --save${C.reset}`
92
92
  ];
93
93
 
94
- const boxWidth = 50;
95
- const border = C.gray;
96
-
97
94
  console.log();
98
- console.log(` ${border}\u256D${"\u2500".repeat(boxWidth)}\u256E${C.reset}`);
95
+ console.log(`${border}\u250C${"\u2500".repeat(boxWidth - 2)}\u2510${C.reset}`);
99
96
 
100
97
  for (const line of lines) {
101
- const rawLen = stripAnsi(line).length;
102
- const pad = boxWidth - rawLen;
103
-
104
- console.log(` ${border}\u2502${C.reset}${line}${" ".repeat(Math.max(0, pad))}${border}\u2502${C.reset}`);
98
+ console.log(`${border}\u2502${C.reset} ${pad(line, boxWidth - 4)} ${border}\u2502${C.reset}`);
105
99
  }
106
100
 
107
- console.log(` ${border}\u2570${"\u2500".repeat(boxWidth)}\u256F${C.reset}`);
108
- console.log();
101
+ console.log(`${border}\u2502${C.reset} ${" ".repeat(boxWidth - 4)} ${border}\u2502${C.reset}`);
102
+ console.log(`${border}\u2514${"\u2500".repeat(boxWidth - 2)}\u2518${C.reset}`);
109
103
  }
110
104
 
111
105
  export default async function checkForUpdates() {
@@ -74,17 +74,18 @@ class DB {
74
74
  }
75
75
 
76
76
  /**
77
- * Executes a raw SQL query.
78
- * @param {string} sql - Raw SQL string
77
+ * Executes a raw SQL query with optional parameter bindings.
78
+ * @param {string} sql - Raw SQL string (use ? for parameter placeholders)
79
+ * @param {Array} bindings - Parameter bindings for safe query execution
79
80
  * @returns {Promise<Array>} Query result
80
81
  * @throws {Error} If database is disabled
81
82
  */
82
- static async rawQuery(sql) {
83
+ static async rawQuery(sql, bindings = []) {
83
84
  if (!this.#driver) {
84
85
  throw new Error("Database is disabled (DATABASE_DRIVER=none)");
85
86
  }
86
87
 
87
- return await this.#driver.raw(sql);
88
+ return await this.#driver.raw(sql, bindings);
88
89
  }
89
90
 
90
91
  /**
@@ -105,7 +106,27 @@ class DB {
105
106
  const timeoutId = setTimeout(() => controller.abort(), timeout);
106
107
 
107
108
  try {
108
- const result = await this.#driver.withTransaction(callback, { signal: controller.signal });
109
+ const result = await this.#driver.withTransaction((connection) => {
110
+ const connectionWrapper = {
111
+ query: (sql, bindings) => connection.execute(sql, bindings)
112
+ };
113
+
114
+ const trx = {
115
+ table: (table, modelClass = null) => createQueryBuilder(table, connectionWrapper, modelClass),
116
+ rawQuery: (sql, bindings = []) => {
117
+ if (bindings.length > 0) {
118
+ return connection.execute(sql, bindings);
119
+ }
120
+
121
+ return connection.query(sql);
122
+ },
123
+ query: (...args) => connection.query(...args),
124
+ execute: (...args) => connection.execute(...args)
125
+ };
126
+
127
+ return callback(trx);
128
+ }, { signal: controller.signal });
129
+
109
130
  clearTimeout(timeoutId);
110
131
 
111
132
  return result;
@@ -147,6 +168,13 @@ class DB {
147
168
  };
148
169
 
149
170
  this.#driver = this.#createDriver("mysql");
171
+
172
+ const isHealthy = await this.#driver.healthCheck();
173
+
174
+ if (!isHealthy) {
175
+ this.#driver = null;
176
+ throw new Error("Database reconfiguration failed: health check failed. Check your credentials.");
177
+ }
150
178
  }
151
179
 
152
180
  /** @private */
@@ -30,11 +30,11 @@ class MySQLDriver {
30
30
  charset: this.#config.charset || "utf8mb4",
31
31
  waitForConnections: true,
32
32
  connectionLimit: this.#config.pool?.max ?? 10,
33
+ idleTimeout: this.#config.pool?.idleTimeout ?? 60000,
33
34
  queueLimit: this.#config.pool?.queueLimit ?? 100,
34
35
  connectTimeout: 10000,
35
36
  enableKeepAlive: true,
36
37
  keepAliveInitialDelay: 10000,
37
- namedPlaceholders: true,
38
38
  decimalNumbers: true
39
39
  });
40
40
  }
@@ -67,12 +67,16 @@ class MySQLDriver {
67
67
  * @param {string} sql - Raw SQL string
68
68
  * @returns {Promise<Array>} Query result
69
69
  */
70
- async raw(sql) {
70
+ async raw(sql, bindings = []) {
71
71
  try {
72
+ if (bindings.length > 0) {
73
+ return await this.#pool.execute(sql, bindings);
74
+ }
75
+
72
76
  return await this.#pool.query(sql);
73
77
  }
74
78
  catch (error) {
75
- throw this.#handleError(error, sql);
79
+ throw this.#handleError(error, sql, bindings.length > 0 ? bindings : null);
76
80
  }
77
81
  }
78
82
 
@@ -175,18 +175,30 @@ class Model {
175
175
  */
176
176
  async save() {
177
177
  const table = this.constructor.table;
178
- const data = {};
179
178
 
180
- for (const [key, value] of Object.entries(this._attributes)) {
181
- if (value !== undefined && (key !== 'id' || !this._exists)) {
182
- data[key] = value;
179
+ if (this._exists) {
180
+ const dirty = {};
181
+
182
+ for (const [key, value] of Object.entries(this._attributes)) {
183
+ if (key === 'id') continue;
184
+ if (value !== this._original[key]) {
185
+ dirty[key] = value;
186
+ }
183
187
  }
184
- }
185
188
 
186
- if (this._exists) {
187
- await DB.table(table).where('id', this._attributes.id).update(data);
189
+ if (Object.keys(dirty).length > 0) {
190
+ await DB.table(table).where('id', this._attributes.id).update(dirty);
191
+ }
188
192
  }
189
193
  else {
194
+ const data = {};
195
+
196
+ for (const [key, value] of Object.entries(this._attributes)) {
197
+ if (value !== undefined) {
198
+ data[key] = value;
199
+ }
200
+ }
201
+
190
202
  const id = await DB.table(table).insert(data);
191
203
  this._attributes.id = id;
192
204
  this._exists = true;
@@ -264,10 +276,10 @@ function hydrate(modelClass, row) {
264
276
 
265
277
  const QUERY_METHODS = [
266
278
  // Chain methods (return QueryBuilder)
267
- 'distinct', 'orWhere', 'whereIn', 'whereNotIn',
279
+ 'selectRaw', 'distinct', 'orWhere', 'whereIn', 'whereNotIn',
268
280
  'whereBetween', 'whereNot', 'join', 'groupBy', 'offset',
269
281
  // Terminal methods (return results)
270
- 'count', 'max', 'min', 'sum', 'avg', 'delete'
282
+ 'count', 'countDistinct', 'max', 'min', 'sum', 'avg', 'delete'
271
283
  ];
272
284
 
273
285
  for (const method of QUERY_METHODS) {
@@ -1,4 +1,4 @@
1
- import { validateDirection, validateIdentifier, validateWhereOperator } from "./QueryValidation.js";
1
+ import { validateDirection, validateIdentifier, validateRawExpression, validateWhereOperator } from "./QueryValidation.js";
2
2
  import Environment from "../Core/Environment.js";
3
3
 
4
4
  /**
@@ -22,7 +22,6 @@ class QueryBuilder {
22
22
  #joins = [];
23
23
  #orders = [];
24
24
  #groups = [];
25
- #havings = [];
26
25
  #limitValue = null;
27
26
  #offsetValue = null;
28
27
  #distinctFlag = false;
@@ -90,19 +89,21 @@ class QueryBuilder {
90
89
  return operator;
91
90
  }
92
91
 
93
- #validateInteger(value) {
92
+ #validateInteger(value, maxAllowed = 10000000) {
94
93
  const stringValue = String(value).trim();
94
+
95
95
  if (!/^\d+$/.test(stringValue)) {
96
96
  throw new Error(`Invalid integer: "${value}". Must be a pure positive number.`);
97
97
  }
98
98
 
99
99
  const num = parseInt(stringValue, 10);
100
+
100
101
  if (isNaN(num) || num < 0) {
101
102
  throw new Error(`Invalid integer: "${value}". Must be a non-negative number.`);
102
103
  }
103
104
 
104
- if (num > 100000) {
105
- throw new Error(`Invalid integer: "${value}". Maximum allowed value is 100000.`);
105
+ if (num > maxAllowed) {
106
+ throw new Error(`Invalid integer: "${value}". Maximum allowed value is ${maxAllowed}.`);
106
107
  }
107
108
 
108
109
  return num;
@@ -157,7 +158,6 @@ class QueryBuilder {
157
158
  this.#joins = [];
158
159
  this.#orders = [];
159
160
  this.#groups = [];
160
- this.#havings = [];
161
161
  this.#limitValue = null;
162
162
  this.#offsetValue = null;
163
163
  this.#distinctFlag = false;
@@ -179,6 +179,45 @@ class QueryBuilder {
179
179
  return this;
180
180
  }
181
181
 
182
+ /**
183
+ * Add a raw expression to the SELECT clause.
184
+ * If the current selection is the default '*', replaces it.
185
+ * Otherwise appends to existing selections.
186
+ *
187
+ * @param {string} expression - Raw SQL expression
188
+ * @returns {QueryBuilder}
189
+ *
190
+ * @example
191
+ * // Standalone
192
+ * DB.table("orders").selectRaw("COUNT(*) as total").first();
193
+ *
194
+ * // Combined with select
195
+ * DB.table("orders")
196
+ * .select("status")
197
+ * .selectRaw("COUNT(*) as total")
198
+ * .selectRaw("AVG(amount) as avg_amount")
199
+ * .groupBy("status")
200
+ * .get();
201
+ */
202
+ selectRaw(expression) {
203
+ if (typeof expression !== 'string' || expression.trim() === '') {
204
+ throw new Error('selectRaw() requires a non-empty string expression.');
205
+ }
206
+
207
+ validateRawExpression(expression);
208
+
209
+ const raw = new RawExpression(expression);
210
+
211
+ if (this.#selectColumns.length === 1 && this.#selectColumns[0] === '*') {
212
+ this.#selectColumns = [raw];
213
+ }
214
+ else {
215
+ this.#selectColumns.push(raw);
216
+ }
217
+
218
+ return this;
219
+ }
220
+
182
221
  distinct() {
183
222
  this.#distinctFlag = true;
184
223
 
@@ -206,6 +245,19 @@ class QueryBuilder {
206
245
  operator = this.#validateWhereOperator(operator);
207
246
  value = this.#validateWhereValue(value);
208
247
 
248
+ // where("column", null) → IS NULL, where("column", "!=", null) → IS NOT NULL
249
+ if (value === null || value === undefined) {
250
+ const isNot = operator === '!=' || operator === '<>' || operator === 'IS NOT';
251
+
252
+ this.#wheres.push({
253
+ type: isNot ? 'notNull' : 'null',
254
+ column: this.#validateIdentifier(column),
255
+ boolean: 'AND'
256
+ });
257
+
258
+ return this;
259
+ }
260
+
209
261
  this.#wheres.push({
210
262
  type: 'basic',
211
263
  column: this.#validateIdentifier(column),
@@ -228,6 +280,18 @@ class QueryBuilder {
228
280
  operator = this.#validateWhereOperator(operator);
229
281
  value = this.#validateWhereValue(value);
230
282
 
283
+ if (value === null || value === undefined) {
284
+ const isNot = operator === '!=' || operator === '<>' || operator === 'IS NOT';
285
+
286
+ this.#wheres.push({
287
+ type: isNot ? 'notNull' : 'null',
288
+ column: this.#validateIdentifier(column),
289
+ boolean: 'OR'
290
+ });
291
+
292
+ return this;
293
+ }
294
+
231
295
  this.#wheres.push({
232
296
  type: 'basic',
233
297
  column: this.#validateIdentifier(column),
@@ -363,7 +427,7 @@ class QueryBuilder {
363
427
  }
364
428
 
365
429
  limit(value) {
366
- this.#limitValue = this.#validateInteger(value);
430
+ this.#limitValue = this.#validateInteger(value, 100000);
367
431
 
368
432
  return this;
369
433
  }
@@ -453,6 +517,40 @@ class QueryBuilder {
453
517
  return parseInt(result) || 0;
454
518
  }
455
519
 
520
+ /**
521
+ * Count distinct values of a column.
522
+ * @param {string} column - Column name to count distinct values of
523
+ * @returns {Promise<number>}
524
+ *
525
+ * @example
526
+ * const uniqueVisitors = await Log_View
527
+ * .where("created_at", ">=", "2026-01-01")
528
+ * .countDistinct("ip_address");
529
+ */
530
+ async countDistinct(column) {
531
+ const connection = await this.#getConnection();
532
+
533
+ try {
534
+ const validatedColumn = this.#validateIdentifier(column);
535
+ const originalSelect = this.#selectColumns;
536
+ this.#selectColumns = [new RawExpression(`COUNT(DISTINCT ${validatedColumn}) as count_distinct`)];
537
+ this.#limitValue = 1;
538
+
539
+ const sql = this.#toSql();
540
+ const [rows] = await connection.query(sql, this.#bindings);
541
+
542
+ this.#selectColumns = originalSelect;
543
+
544
+ return rows[0] ? parseInt(rows[0].count_distinct) || 0 : 0;
545
+ }
546
+ catch (error) {
547
+ throw this.#sanitizeError(error);
548
+ }
549
+ finally {
550
+ this.#reset();
551
+ }
552
+ }
553
+
456
554
  async max(column) {
457
555
  return await this.#aggregate('MAX', column);
458
556
  }
@@ -474,20 +572,24 @@ class QueryBuilder {
474
572
  }
475
573
 
476
574
  async #aggregate(func, column) {
575
+ const connection = await this.#getConnection();
477
576
  const alias = func.toLowerCase();
478
- const originalSelect = this.#selectColumns;
479
577
 
480
578
  try {
481
579
  const validatedColumn = column === '*' ? '*' : this.#validateIdentifier(column);
580
+ const originalSelect = this.#selectColumns;
482
581
  this.#selectColumns = [new RawExpression(`${func}(${validatedColumn}) as ${alias}`)];
582
+ this.#limitValue = 1;
483
583
 
484
- const row = await this.first(); // first() handles reset internally
485
-
486
- return row ? row[alias] : null;
487
- }
584
+ const sql = this.#toSql();
585
+ const [rows] = await connection.query(sql, this.#bindings);
488
586
 
489
- finally {
490
587
  this.#selectColumns = originalSelect;
588
+
589
+ return rows[0] ? rows[0][alias] : null;
590
+ }
591
+ catch (error) {
592
+ throw this.#sanitizeError(error);
491
593
  }
492
594
  }
493
595
 
@@ -585,11 +687,10 @@ class QueryBuilder {
585
687
  const joins = this.#compileJoins();
586
688
  const wheres = this.#compileWheres();
587
689
  const groups = this.#compileGroupBy();
588
- const havings = this.#compileHaving();
589
690
  const orders = this.#compileOrders();
590
691
  const limit = this.#compileLimit();
591
692
 
592
- return `SELECT ${distinct}${columns} FROM ${this.#quoteIdentifier(this.#table)}${joins}${wheres}${groups}${havings}${orders}${limit}`;
693
+ return `SELECT ${distinct}${columns} FROM ${this.#quoteIdentifier(this.#table)}${joins}${wheres}${groups}${orders}${limit}`;
593
694
  }
594
695
 
595
696
  #compileJoins() {
@@ -647,19 +748,7 @@ class QueryBuilder {
647
748
  return ` GROUP BY ${this.#groups.join(', ')}`;
648
749
  }
649
750
 
650
- #compileHaving() {
651
- if (this.#havings.length === 0) {
652
- return '';
653
- }
654
-
655
- const compiled = this.#havings.map((having, index) => {
656
- const boolean = index === 0 ? '' : ' AND ';
657
-
658
- return `${boolean}${having.column} ${having.operator} ?`;
659
- }).join('');
660
751
 
661
- return ` HAVING ${compiled}`;
662
- }
663
752
 
664
753
  #compileOrders() {
665
754
  if (this.#orders.length === 0) {
@@ -74,6 +74,38 @@ export function validateDirection(direction) {
74
74
  return upper;
75
75
  }
76
76
 
77
+ /**
78
+ * Validates a raw SQL expression for use in selectRaw().
79
+ * Blocks dangerous patterns while allowing legitimate aggregate/function expressions.
80
+ *
81
+ * Allowed: COUNT(*), AVG(col), DISTINCT, AS aliases, DATE(), CASE WHEN, math operators
82
+ * Blocked: Semicolons, DDL (DROP/ALTER/CREATE/TRUNCATE), DML writes (INSERT/UPDATE/DELETE),
83
+ * UNION, SQL comments, system functions (LOAD_FILE, INTO OUTFILE), subqueries
84
+ *
85
+ * @param {string} expression - Raw SQL expression to validate
86
+ * @throws {Error} If expression contains dangerous patterns
87
+ */
88
+ export function validateRawExpression(expression) {
89
+ if (typeof expression !== 'string' || expression.trim() === '') {
90
+ throw new Error('Raw expression must be a non-empty string.');
91
+ }
92
+
93
+ if (/;/.test(expression)) {
94
+ throw new Error(`Dangerous raw expression: semicolons are not allowed.`);
95
+ }
96
+
97
+ if (/--|#|\/\*|\*\//.test(expression)) {
98
+ throw new Error(`Dangerous raw expression: SQL comments are not allowed.`);
99
+ }
100
+
101
+ const dangerous = /\b(DROP|ALTER|CREATE|TRUNCATE|INSERT|UPDATE|DELETE|REPLACE|GRANT|REVOKE|UNION|INTO\s+OUTFILE|INTO\s+DUMPFILE|LOAD_FILE|BENCHMARK|SLEEP|INFORMATION_SCHEMA)\b/i;
102
+
103
+ if (dangerous.test(expression)) {
104
+ const match = expression.match(dangerous);
105
+ throw new Error(`Dangerous raw expression: "${match[0].toUpperCase()}" is not allowed in selectRaw().`);
106
+ }
107
+ }
108
+
77
109
  // ─────────────────────────────────────────────────────────────────────────────
78
110
  // Private Functions
79
111
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1,6 +1,8 @@
1
1
  class Blueprint {
2
2
  #tableName;
3
3
  #columns = [];
4
+ #indexes = [];
5
+ #dropIndexes = [];
4
6
 
5
7
  constructor(tableName) {
6
8
  this.#tableName = tableName;
@@ -18,6 +20,24 @@ class Blueprint {
18
20
  return this.#columns;
19
21
  }
20
22
 
23
+ getIndexes() {
24
+ return this.#indexes;
25
+ }
26
+
27
+ getDropIndexes() {
28
+ return this.#dropIndexes;
29
+ }
30
+
31
+ index(columns) {
32
+ const cols = Array.isArray(columns) ? columns : [columns];
33
+ const name = `idx_${this.#tableName}_${cols.join('_')}`;
34
+ this.#indexes.push({ name, columns: cols });
35
+ }
36
+
37
+ dropIndex(name) {
38
+ this.#dropIndexes.push(name);
39
+ }
40
+
21
41
  id() {
22
42
  this.#columns.push({ name: 'id', type: 'id', modifiers: {} });
23
43
  return this;
@@ -64,11 +84,15 @@ class Blueprint {
64
84
  };
65
85
  this.#columns.push(column);
66
86
 
67
- return {
68
- nullable: () => { column.modifiers.nullable = true; return this; },
69
- default: (value) => { column.modifiers.default = value; return this; },
70
- unique: () => { column.modifiers.unique = true; return this; }
87
+ const blueprint = this;
88
+ const chain = {
89
+ nullable() { column.modifiers.nullable = true; return chain; },
90
+ default(value) { column.modifiers.default = value; return chain; },
91
+ unique() { column.modifiers.unique = true; return chain; },
92
+ index() { blueprint.index(name); return chain; },
71
93
  };
94
+
95
+ return chain;
72
96
  }
73
97
  }
74
98
 
@@ -16,6 +16,17 @@ export default class Schema {
16
16
  await DB.rawQuery(this.#buildCreateSQL(blueprint, true));
17
17
  }
18
18
 
19
+ static async table(tableName, callback) {
20
+ const blueprint = new (await import('./Blueprint.js')).default(tableName);
21
+ callback(blueprint);
22
+
23
+ const statements = this.#buildAlterSQL(blueprint);
24
+
25
+ for (const sql of statements) {
26
+ await DB.rawQuery(sql);
27
+ }
28
+ }
29
+
19
30
  static async dropIfExists(tableName) {
20
31
  await DB.rawQuery(`DROP TABLE IF EXISTS \`${tableName}\``);
21
32
  }
@@ -23,13 +34,36 @@ export default class Schema {
23
34
  // Private Methods
24
35
  static #buildCreateSQL(blueprint, ifNotExists = false) {
25
36
  const columns = blueprint.getColumns().map(col => this.#buildColumnSQL(col));
37
+ const indexes = blueprint.getIndexes().map(idx => this.#buildIndexSQL(idx));
38
+ const definitions = [...columns, ...indexes];
26
39
  const dbConfig = Config.all('database').connections.mysql;
27
40
 
28
41
  const charset = dbConfig.charset || 'utf8mb4';
29
42
  const collation = dbConfig.collation || 'utf8mb4_unicode_ci';
30
43
  const ifNotExistsClause = ifNotExists ? 'IF NOT EXISTS ' : '';
31
44
 
32
- return `CREATE TABLE ${ifNotExistsClause}\`${blueprint.getTableName()}\` (\n ${columns.join(',\n ')}\n) ENGINE=InnoDB DEFAULT CHARSET=${charset} COLLATE=${collation}`;
45
+ return `CREATE TABLE ${ifNotExistsClause}\`${blueprint.getTableName()}\` (\n ${definitions.join(',\n ')}\n) ENGINE=InnoDB DEFAULT CHARSET=${charset} COLLATE=${collation}`;
46
+ }
47
+
48
+ static #buildAlterSQL(blueprint) {
49
+ const tableName = blueprint.getTableName();
50
+ const statements = [];
51
+
52
+ for (const idx of blueprint.getIndexes()) {
53
+ const cols = idx.columns.map(c => `\`${c}\``).join(', ');
54
+ statements.push(`ALTER TABLE \`${tableName}\` ADD INDEX \`${idx.name}\` (${cols})`);
55
+ }
56
+
57
+ for (const name of blueprint.getDropIndexes()) {
58
+ statements.push(`ALTER TABLE \`${tableName}\` DROP INDEX \`${name}\``);
59
+ }
60
+
61
+ return statements;
62
+ }
63
+
64
+ static #buildIndexSQL(idx) {
65
+ const cols = idx.columns.map(c => `\`${c}\``).join(', ');
66
+ return `INDEX \`${idx.name}\` (${cols})`;
33
67
  }
34
68
 
35
69
  static #buildColumnSQL(column) {
@@ -55,10 +89,18 @@ export default class Schema {
55
89
  if (column.modifiers) {
56
90
  sql += column.modifiers.nullable ? ' NULL' : ' NOT NULL';
57
91
  if (column.modifiers.default !== null) {
58
- const val = typeof column.modifiers.default === 'string'
59
- ? `'${column.modifiers.default}'`
60
- : column.modifiers.default;
61
- sql += ` DEFAULT ${val}`;
92
+ const raw = column.modifiers.default;
93
+
94
+ if (typeof raw === 'string') {
95
+ const escaped = raw.replace(/'/g, "''");
96
+ sql += ` DEFAULT '${escaped}'`;
97
+ }
98
+ else if (typeof raw === 'boolean') {
99
+ sql += ` DEFAULT ${raw ? 1 : 0}`;
100
+ }
101
+ else {
102
+ sql += ` DEFAULT ${Number(raw)}`;
103
+ }
62
104
  }
63
105
  if (column.modifiers.unique) sql += ' UNIQUE';
64
106
  }
@@ -1,14 +1,14 @@
1
1
  import crypto from "crypto";
2
2
 
3
3
  /**
4
- * AES-256-CBC encryption and decryption utility.
4
+ * AES-256-GCM authenticated encryption and decryption utility.
5
5
  * Uses APP_KEY from environment for secure encryption.
6
6
  */
7
7
  class Encryption {
8
8
  /**
9
- * Encrypts a value using AES-256-CBC.
9
+ * Encrypts a value using AES-256-GCM with authentication tag.
10
10
  * @param {string|Object} value - Value to encrypt (objects are JSON stringified)
11
- * @returns {string} Encrypted string in format "iv:encryptedData"
11
+ * @returns {string} Encrypted string in format "iv:authTag:encryptedData"
12
12
  */
13
13
  static encrypt(value) {
14
14
  if (typeof value === "object") {
@@ -16,27 +16,35 @@ class Encryption {
16
16
  }
17
17
 
18
18
  const secretKey = crypto.createHash("sha256").update(process.env.APP_KEY).digest();
19
- const iv = crypto.randomBytes(16);
20
- const cipher = crypto.createCipheriv("aes-256-cbc", secretKey, iv);
19
+ const iv = crypto.randomBytes(12);
20
+ const cipher = crypto.createCipheriv("aes-256-gcm", secretKey, iv);
21
21
 
22
22
  let encrypted = cipher.update(value, "utf8", "hex");
23
23
  encrypted += cipher.final("hex");
24
+ const authTag = cipher.getAuthTag().toString("hex");
24
25
 
25
- return iv.toString("hex") + ":" + encrypted;
26
+ return iv.toString("hex") + ":" + authTag + ":" + encrypted;
26
27
  }
27
28
 
28
29
  /**
29
- * Decrypts an AES-256-CBC encrypted value.
30
- * @param {string} encryptedValue - Encrypted string in format "iv:encryptedData"
30
+ * Decrypts an AES-256-GCM encrypted value.
31
+ * @param {string} encryptedValue - Encrypted string in format "iv:authTag:encryptedData"
31
32
  * @returns {string|false} Decrypted string or false if decryption fails
32
33
  */
33
34
  static decrypt(encryptedValue) {
34
35
  try {
35
36
  const parts = encryptedValue.split(":");
36
- const iv = Buffer.from(parts.shift(), "hex");
37
- const encryptedText = Buffer.from(parts.join(":"), "hex");
37
+
38
+ if (parts.length < 3) {
39
+ return false;
40
+ }
41
+
42
+ const iv = Buffer.from(parts[0], "hex");
43
+ const authTag = Buffer.from(parts[1], "hex");
44
+ const encryptedText = Buffer.from(parts.slice(2).join(":"), "hex");
38
45
  const secretKey = crypto.createHash("sha256").update(process.env.APP_KEY).digest();
39
- const decipher = crypto.createDecipheriv("aes-256-cbc", secretKey, iv);
46
+ const decipher = crypto.createDecipheriv("aes-256-gcm", secretKey, iv);
47
+ decipher.setAuthTag(authTag);
40
48
 
41
49
  let decrypted = decipher.update(encryptedText, "hex", "utf8");
42
50
  decrypted += decipher.final("utf8");
@@ -62,7 +62,9 @@ class Storage {
62
62
  const base = isPrivate ? this.#privateRoot : this.#publicRoot;
63
63
  const fullPath = this.#validatePath(base, filePath);
64
64
 
65
- await fs.promises.unlink(fullPath);
65
+ await fs.promises.unlink(fullPath).catch(err => {
66
+ if (err.code !== "ENOENT") throw err;
67
+ });
66
68
  }
67
69
 
68
70
  /**
@@ -42,6 +42,11 @@ class Server {
42
42
  dotenv.config({ quiet: true });
43
43
  await Config.initialize();
44
44
 
45
+ if (!process.env.APP_KEY || process.env.APP_KEY.trim() === "") {
46
+ console.error("\x1b[31m✕ APP_KEY is not set. Please set APP_KEY in your .env file before starting the server.\x1b[0m");
47
+ process.exit(1);
48
+ }
49
+
45
50
  this.#config = Config.all("server");
46
51
  const { web_server = {}, cors = {} } = this.#config;
47
52
 
@@ -335,7 +340,7 @@ class Server {
335
340
 
336
341
  // Private Methods
337
342
  static #printBanner({ success, address, host, port, error }) {
338
- if (this.#config.log.channel !== "none" && this.#config.log.channel !== "file") {
343
+ if (this.#config.log.channel === "console") {
339
344
  return;
340
345
  }
341
346
 
@@ -9,6 +9,7 @@ import Config from "../Core/Config.js";
9
9
  class Log {
10
10
  static #levels = { debug: 0, info: 1, warn: 2, error: 3, fatal: 4 };
11
11
  static #levelLabels = { debug: "DEBUG", info: "INFO", warn: "WARN", error: "ERROR", fatal: "FATAL" };
12
+ static #dirEnsured = false;
12
13
 
13
14
  /**
14
15
  * Logs a debug message.
@@ -158,10 +159,15 @@ class Log {
158
159
  /** @private */
159
160
  static #logToFile(level, message, context, timestamp, config) {
160
161
  const filePath = path.resolve(process.cwd(), config.file);
161
- const dir = path.dirname(filePath);
162
162
 
163
- if (!fs.existsSync(dir)) {
164
- fs.mkdirSync(dir, { recursive: true });
163
+ if (!this.#dirEnsured) {
164
+ const dir = path.dirname(filePath);
165
+
166
+ if (!fs.existsSync(dir)) {
167
+ fs.mkdirSync(dir, { recursive: true });
168
+ }
169
+
170
+ this.#dirEnsured = true;
165
171
  }
166
172
 
167
173
  const time = this.#formatTimestamp(timestamp);
@@ -54,11 +54,25 @@ class HotReloadRegistry {
54
54
  if (!info) return handler;
55
55
 
56
56
  const { filePath, methodName } = info;
57
+ let cachedModule = null;
58
+ let cachedMtime = 0;
57
59
 
58
60
  return async (request, response, param) => {
59
- const module = await import(`file://${filePath}?t=${Date.now()}`);
61
+ let mtime = 0;
60
62
 
61
- return module.default[methodName](request, response, param);
63
+ try {
64
+ mtime = fs.statSync(filePath).mtimeMs;
65
+ }
66
+ catch {
67
+ mtime = Date.now();
68
+ }
69
+
70
+ if (!cachedModule || mtime !== cachedMtime) {
71
+ cachedModule = await import(`file://${filePath}?t=${mtime}`);
72
+ cachedMtime = mtime;
73
+ }
74
+
75
+ return cachedModule.default[methodName](request, response, param);
62
76
  };
63
77
  }
64
78
 
@@ -391,25 +405,30 @@ class RouteGroup {
391
405
 
392
406
  group(callback) {
393
407
  const beforeCount = this.routes.length;
394
- callback();
395
- const newRoutes = this.routes.slice(beforeCount);
396
-
397
- for (const route of newRoutes) {
398
- // Apply prefix
399
- if (this.options.prefix) {
400
- route.url = route.url === "/"
401
- ? this.options.prefix
402
- : this.options.prefix + route.url;
403
- }
404
408
 
405
- // Apply name prefix
406
- if (route.name && this.options.name) {
407
- route.name = this.options.name + route.name;
409
+ const applyGroup = () => {
410
+ const newRoutes = this.routes.slice(beforeCount);
411
+ for (const route of newRoutes) {
412
+ if (this.options.prefix) {
413
+ route.url = route.url === "/"
414
+ ? this.options.prefix
415
+ : this.options.prefix + route.url;
416
+ }
417
+ if (route.name && this.options.name) {
418
+ route.name = this.options.name + route.name;
419
+ }
420
+ route.middlewares.unshift(...this.options.middlewares);
408
421
  }
422
+ };
423
+
424
+ const result = callback();
409
425
 
410
- // Prepend middlewares
411
- route.middlewares.unshift(...this.options.middlewares);
426
+ // Sync callback: apply immediately. Async callback: apply after resolution.
427
+ if (result && typeof result.then === "function") {
428
+ return result.then(() => applyGroup());
412
429
  }
430
+
431
+ applyGroup();
413
432
  }
414
433
  }
415
434
 
@@ -19,13 +19,24 @@ class SessionManager {
19
19
  * Gets the singleton instance.
20
20
  * @returns {Promise<SessionManager>}
21
21
  */
22
+ static #initPromise = null;
23
+
22
24
  static async getInstance() {
23
- if (!this.#instance) {
24
- this.#instance = new SessionManager();
25
- await this.#instance.#initialize();
25
+ if (this.#instance) {
26
+ return this.#instance;
27
+ }
28
+
29
+ if (!this.#initPromise) {
30
+ this.#initPromise = (async () => {
31
+ const manager = new SessionManager();
32
+ await manager.#initialize();
33
+ this.#instance = manager;
34
+
35
+ return manager;
36
+ })();
26
37
  }
27
38
 
28
- return this.#instance;
39
+ return this.#initPromise;
29
40
  }
30
41
 
31
42
  /** @private */
package/lib/View/View.js CHANGED
@@ -175,23 +175,24 @@ class View {
175
175
  });
176
176
  });
177
177
 
178
- if (View.#isDev) {
179
- server.get("/__nitron/navigate", async (req, res) => {
180
- const url = req.query.url;
181
- if (!url) {
182
- return res.code(400).send({ error: "Missing url" });
183
- }
178
+ server.get("/__nitron/navigate", async (req, res) => {
179
+ const url = req.query.url;
184
180
 
185
- const result = await View.#renderPartial(url, req, res);
186
-
187
- if (result.handled) return;
188
-
189
- return res
190
- .code(result.status || 200)
191
- .header("X-Content-Type-Options", "nosniff")
192
- .send(result);
193
- });
181
+ if (!url) {
182
+ return res.code(400).send({ error: "Missing url" });
183
+ }
194
184
 
185
+ const result = await View.#renderPartial(url, req, res);
186
+
187
+ if (result.handled) return;
188
+
189
+ return res
190
+ .code(result.status || 200)
191
+ .header("X-Content-Type-Options", "nosniff")
192
+ .send(result);
193
+ });
194
+
195
+ if (View.#isDev) {
195
196
  server.get("/__nitron/icon.png", async (req, res) => {
196
197
  const iconPath = path.join(__dirname, "Client", "nitronjs-icon.png");
197
198
  const iconBuffer = readFileSync(iconPath);
package/lib/index.d.ts CHANGED
@@ -56,40 +56,84 @@ export class Model {
56
56
  static hidden: string[];
57
57
  static casts: Record<string, string>;
58
58
 
59
+ // Direct static methods
59
60
  static get<T extends Model>(this: new () => T): Promise<T[]>;
60
61
  static find<T extends Model>(this: new () => T, id: number | string): Promise<T | null>;
61
62
  static first<T extends Model>(this: new () => T): Promise<T | null>;
62
- static where<T extends Model>(this: new () => T, column: string, operator?: any, value?: any): QueryBuilder<T>;
63
+ static where<T extends Model>(this: new () => T, column: string, value: string | number | boolean | null): QueryBuilder<T>;
64
+ static where<T extends Model>(this: new () => T, column: string, operator: string, value: string | number | boolean | null): QueryBuilder<T>;
65
+ static where<T extends Model>(this: new () => T, column: Record<string, any>): QueryBuilder<T>;
63
66
  static select<T extends Model>(this: new () => T, ...columns: string[]): QueryBuilder<T>;
64
67
  static orderBy<T extends Model>(this: new () => T, column: string, direction?: 'asc' | 'desc'): QueryBuilder<T>;
65
68
  static limit<T extends Model>(this: new () => T, count: number): QueryBuilder<T>;
66
- static create<T extends Model>(this: new () => T, data: Record<string, any>): Promise<T>;
67
69
 
70
+ // Forwarded from QueryBuilder (QUERY_METHODS)
71
+ static selectRaw<T extends Model>(this: new () => T, expression: string): QueryBuilder<T>;
72
+ static distinct<T extends Model>(this: new () => T): QueryBuilder<T>;
73
+ static orWhere<T extends Model>(this: new () => T, column: string, value: string | number | boolean | null): QueryBuilder<T>;
74
+ static orWhere<T extends Model>(this: new () => T, column: string, operator: string, value: string | number | boolean | null): QueryBuilder<T>;
75
+ static whereIn<T extends Model>(this: new () => T, column: string, values: any[]): QueryBuilder<T>;
76
+ static whereNotIn<T extends Model>(this: new () => T, column: string, values: any[]): QueryBuilder<T>;
77
+ static whereBetween<T extends Model>(this: new () => T, column: string, range: [any, any]): QueryBuilder<T>;
78
+ static whereNot<T extends Model>(this: new () => T, column: string, value: any): QueryBuilder<T>;
79
+ static join<T extends Model>(this: new () => T, table: string, col1: string, operator: string, col2: string): QueryBuilder<T>;
80
+ static groupBy<T extends Model>(this: new () => T, ...columns: string[]): QueryBuilder<T>;
81
+ static offset<T extends Model>(this: new () => T, count: number): QueryBuilder<T>;
82
+ static count<T extends Model>(this: new () => T, column?: string): Promise<number>;
83
+ static countDistinct<T extends Model>(this: new () => T, column: string): Promise<number>;
84
+ static max<T extends Model>(this: new () => T, column: string): Promise<number | null>;
85
+ static min<T extends Model>(this: new () => T, column: string): Promise<number | null>;
86
+ static sum<T extends Model>(this: new () => T, column: string): Promise<number>;
87
+ static avg<T extends Model>(this: new () => T, column: string): Promise<number | null>;
88
+
89
+ // Instance methods
68
90
  save(): Promise<this>;
69
91
  delete(): Promise<boolean>;
70
- update(data: Record<string, any>): Promise<this>;
71
- refresh(): Promise<this>;
72
- toJSON(): Record<string, any>;
92
+ toObject(): Record<string, any>;
73
93
 
74
94
  [key: string]: any;
75
95
  }
76
96
 
77
97
  export interface QueryBuilder<T> {
78
- where(column: string, operator?: any, value?: any): QueryBuilder<T>;
79
- orWhere(column: string, operator?: any, value?: any): QueryBuilder<T>;
98
+ // Where clauses
99
+ where(column: string, value: string | number | boolean | null): QueryBuilder<T>;
100
+ where(column: string, operator: string, value: string | number | boolean | null): QueryBuilder<T>;
101
+ where(column: Record<string, any>): QueryBuilder<T>;
102
+ orWhere(column: string, value: string | number | boolean | null): QueryBuilder<T>;
103
+ orWhere(column: string, operator: string, value: string | number | boolean | null): QueryBuilder<T>;
80
104
  whereIn(column: string, values: any[]): QueryBuilder<T>;
81
105
  whereNotIn(column: string, values: any[]): QueryBuilder<T>;
82
- whereNull(column: string): QueryBuilder<T>;
83
- whereNotNull(column: string): QueryBuilder<T>;
106
+ whereNot(column: string, value: any): QueryBuilder<T>;
84
107
  whereBetween(column: string, range: [any, any]): QueryBuilder<T>;
108
+
109
+ // Selection
85
110
  select(...columns: string[]): QueryBuilder<T>;
111
+ selectRaw(expression: string): QueryBuilder<T>;
112
+ distinct(): QueryBuilder<T>;
113
+
114
+ // Joins
115
+ join(table: string, col1: string, operator: string, col2: string): QueryBuilder<T>;
116
+
117
+ // Ordering & Grouping
86
118
  orderBy(column: string, direction?: 'asc' | 'desc'): QueryBuilder<T>;
119
+ groupBy(...columns: string[]): QueryBuilder<T>;
87
120
  limit(count: number): QueryBuilder<T>;
88
121
  offset(count: number): QueryBuilder<T>;
122
+
123
+ // Terminal — fetch
89
124
  get(): Promise<T[]>;
90
125
  first(): Promise<T | null>;
91
- count(): Promise<number>;
92
- exists(): Promise<boolean>;
126
+
127
+ // Terminal — aggregates
128
+ count(column?: string): Promise<number>;
129
+ countDistinct(column: string): Promise<number>;
130
+ max(column: string): Promise<number | null>;
131
+ min(column: string): Promise<number | null>;
132
+ sum(column: string): Promise<number>;
133
+ avg(column: string): Promise<number | null>;
134
+
135
+ // Terminal — mutation
136
+ insert(data: Record<string, any>): Promise<number>;
93
137
  update(data: Record<string, any>): Promise<number>;
94
138
  delete(): Promise<number>;
95
139
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitronjs/framework",
3
- "version": "0.2.23",
3
+ "version": "0.2.26",
4
4
  "description": "NitronJS is a modern and extensible Node.js MVC framework built on Fastify. It focuses on clean architecture, modular structure, and developer productivity, offering built-in routing, middleware, configuration management, CLI tooling, and native React integration for scalable full-stack applications.",
5
5
  "bin": {
6
6
  "njs": "./cli/njs.js"
@@ -1,8 +1,8 @@
1
- import sessionConfig from "../../config/session.js";
1
+ import { Config } from "@nitronjs/framework";
2
2
 
3
3
  class VerifyCsrf {
4
4
  static async handler(req, res) {
5
- const csrf = sessionConfig.csrf;
5
+ const csrf = Config.all("session").csrf;
6
6
  const token = req.body?.[csrf.tokenField] || req.headers[csrf.headerField];
7
7
 
8
8
  if (!token) {