millas 0.2.24 → 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/README.md CHANGED
@@ -89,6 +89,25 @@ class User extends Model {
89
89
  }
90
90
  ```
91
91
 
92
+ ## Configuration
93
+
94
+ ```js
95
+ // config/app.js
96
+ module.exports = {
97
+ name: 'My App',
98
+ env: process.env.NODE_ENV || 'development',
99
+ timezone: 'UTC', // Scheduler timezone (cron timing)
100
+
101
+ // ORM timezone behavior (Django-style)
102
+ useTz: true, // true = treat DB timestamps as UTC (recommended)
103
+ // false = treat DB timestamps as local server time
104
+ // Column type stays TIMESTAMP (without timezone)
105
+ // Conversion happens in application layer
106
+
107
+ allowedHosts: [], // Django-style host validation
108
+ };
109
+ ```
110
+
92
111
  ## Authentication
93
112
 
94
113
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "millas",
3
- "version": "0.2.24",
3
+ "version": "0.2.26",
4
4
  "description": "A modern batteries-included backend framework for Node.js — built on Express, inspired by Laravel, Django, and FastAPI",
5
5
  "main": "src/index.js",
6
6
  "exports": {
@@ -73,6 +73,7 @@ class AppInitializer {
73
73
  */
74
74
  async bootKernel() {
75
75
  const cfg = this._config;
76
+ const basePath = cfg.basePath || process.cwd();
76
77
 
77
78
  const ExpressAdapter = require('../http/adapters/ExpressAdapter');
78
79
  const expressApp = express();
@@ -83,7 +84,6 @@ class AppInitializer {
83
84
  // Reads config/app.js for overrides. All protections are on by default:
84
85
  // security headers, CSRF, rate limiting, cookie defaults, allowed hosts.
85
86
  const SecurityBootstrap = require('../http/SecurityBootstrap');
86
- const basePath = cfg.basePath || process.cwd();
87
87
  const appConfig = SecurityBootstrap.loadConfig(basePath + '/config/app');
88
88
  SecurityBootstrap.apply(this._adapter.nativeApp || expressApp, appConfig);
89
89
 
@@ -47,6 +47,7 @@ const UploadedFile = require('../http/UploadedFile');
47
47
  // ── Serializer ────────────────────────────────────────────────────
48
48
  const { Serializer } = require('../serializer/Serializer');
49
49
  const {Str, FluentString} = require("../support/Str");
50
+ const Time = require('../support/Time');
50
51
 
51
52
  module.exports = {
52
53
  // ── Millas HTTP layer ──────────────────────────────────────────
@@ -68,7 +69,7 @@ module.exports = {
68
69
  // Serializer
69
70
  Serializer,
70
71
  // Support
71
- Str, FluentString,
72
+ Str, FluentString, Time,
72
73
  Log: require('../logger').Log,
73
74
  // Scheduler
74
75
  TaskScheduler: require('../scheduler').TaskScheduler,
@@ -0,0 +1,15 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * core/timezone
5
+ *
6
+ * Timezone utilities for Millas.
7
+ *
8
+ * Usage:
9
+ * const { Time } = require('millas/core/timezone');
10
+ * const now = Time.now();
11
+ */
12
+
13
+ const Time = require('../support/Time');
14
+
15
+ module.exports = { Time };
@@ -30,7 +30,7 @@ class JsonFormatter {
30
30
  const { level, tag, message, context, error, timestamp } = entry;
31
31
 
32
32
  const record = {
33
- ts: timestamp || new Date().toISOString(),
33
+ ts: timestamp instanceof Date ? timestamp.toISOString() : (timestamp || new Date().toISOString()),
34
34
  level: LEVEL_NAMES[level] || String(level),
35
35
  ...this.extra,
36
36
  };
@@ -135,8 +135,9 @@ class PrettyFormatter {
135
135
 
136
136
  _timestamp() {
137
137
  const now = new Date();
138
- if (this.tsFormat === 'iso') return now.toISOString();
139
- return now.toISOString().replace('T', ' ').slice(0, 19);
138
+ const isoStr = now.toISOString();
139
+ if (this.tsFormat === 'iso') return isoStr;
140
+ return isoStr.replace('T', ' ').slice(0, 19);
140
141
  }
141
142
  }
142
143
 
@@ -26,7 +26,8 @@ class SimpleFormatter {
26
26
  format(entry) {
27
27
  const { level, tag, message, context, error, timestamp } = entry;
28
28
 
29
- const ts = (timestamp || new Date().toISOString()).replace('T', ' ').slice(0, 23);
29
+ const isoStr = timestamp instanceof Date ? timestamp.toISOString() : (timestamp || new Date().toISOString());
30
+ const ts = isoStr.replace('T', ' ').slice(0, 23);
30
31
  const lvlName = (LEVEL_NAMES[level] || String(level)).padEnd(7);
31
32
  const tagPart = tag ? `${tag}: ` : '';
32
33
 
@@ -206,7 +206,21 @@ class DatabaseManager {
206
206
  'Run: npm install pg'
207
207
  );
208
208
  }
209
- return knex({
209
+
210
+ // Configure pg to parse timestamps as UTC
211
+ const types = require('pg').types;
212
+ const TIMESTAMP_OID = 1114; // timestamp without timezone
213
+ const TIMESTAMPTZ_OID = 1184; // timestamp with timezone
214
+
215
+ // Override pg's default timestamp parser to always return UTC
216
+ types.setTypeParser(TIMESTAMP_OID, (val) => {
217
+ return val === null ? null : new Date(val + 'Z'); // Treat as UTC
218
+ });
219
+ types.setTypeParser(TIMESTAMPTZ_OID, (val) => {
220
+ return val === null ? null : new Date(val); // Already has timezone
221
+ });
222
+
223
+ const pgConnection = knex({
210
224
  client: 'pg',
211
225
  connection: {
212
226
  host: conf.host,
@@ -215,8 +229,15 @@ class DatabaseManager {
215
229
  user: conf.username,
216
230
  password: conf.password,
217
231
  },
218
- pool: { min: 2, max: 10 },
232
+ pool: {
233
+ min: 2,
234
+ max: 10,
235
+ afterCreate: (conn, done) => {
236
+ conn.query('SET timezone = "UTC";', (err) => done(err, conn));
237
+ },
238
+ },
219
239
  });
240
+ return pgConnection;
220
241
 
221
242
  default:
222
243
  throw new Error(`Unsupported database driver: "${conf.driver}"`);
@@ -569,6 +569,10 @@ class Model {
569
569
  return new QueryBuilder(this._db(), this).select(...cols);
570
570
  }
571
571
 
572
+ static selectRaw(sql, bindings = []) {
573
+ return new QueryBuilder(this._db(), this).selectRaw(sql, bindings);
574
+ }
575
+
572
576
  static distinct(...cols) {
573
577
  return new QueryBuilder(this._db(), this).distinct(...cols);
574
578
  }
@@ -967,6 +971,15 @@ class Model {
967
971
  const d = new Date(val.valueOf?.() ?? val);
968
972
  return isNaN(d.getTime()) ? null : d;
969
973
  }
974
+ // Django-style USE_TZ: if enabled, treat naive DB timestamps as UTC
975
+ if (this._isUseTzEnabled()) {
976
+ // Parse as UTC by appending 'Z' if no timezone info present
977
+ const str = String(val);
978
+ if (!str.includes('Z') && !str.includes('+') && !str.includes('-', 10)) {
979
+ const d = new Date(str + 'Z');
980
+ return isNaN(d.getTime()) ? null : d;
981
+ }
982
+ }
970
983
  const d = new Date(val);
971
984
  return isNaN(d.getTime()) ? null : d;
972
985
  }
@@ -1038,6 +1051,15 @@ class Model {
1038
1051
  } catch { return false; }
1039
1052
  }
1040
1053
 
1054
+ static _isUseTzEnabled() {
1055
+ try {
1056
+ const appConfig = require(process.cwd() + '/config/app.js');
1057
+ return appConfig.useTz !== false; // Default to true (Django-style)
1058
+ } catch {
1059
+ return true;
1060
+ }
1061
+ }
1062
+
1041
1063
  /**
1042
1064
  * Insert a row and return the inserted primary key — dialect-aware.
1043
1065
  * SQLite: insert returns [lastId]
@@ -78,6 +78,12 @@ class RouteEntry {
78
78
  * Add extra middleware to this specific route after registration.
79
79
  * Middleware aliases are appended to the existing list.
80
80
  *
81
+ * Supports Laravel-style parameters:
82
+ * .middleware('auth')
83
+ * .middleware(['auth', 'throttle'])
84
+ * .middleware('verifyAction:payment_method_delete')
85
+ * .middleware(['auth', 'verifyAction:payment_method_delete'])
86
+ *
81
87
  * @param {string|string[]} middleware
82
88
  * @returns {RouteEntry}
83
89
  */
@@ -86,6 +92,18 @@ class RouteEntry {
86
92
  this._entry.middleware = [...(this._entry.middleware || []), ...list];
87
93
  return this;
88
94
  }
95
+
96
+ /**
97
+ * Set route name (Laravel-style).
98
+ * Route.get('/users', UserController, 'index').name('users.index')
99
+ *
100
+ * @param {string} name
101
+ * @returns {RouteEntry}
102
+ */
103
+ name(name) {
104
+ this._entry.name = name;
105
+ return this;
106
+ }
89
107
  }
90
108
 
91
109
  module.exports = RouteEntry;
@@ -0,0 +1,216 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Time
5
+ *
6
+ * Timezone utilities for Millas.
7
+ * Provides helpers for timezone conversion and formatting.
8
+ *
9
+ * Note: JavaScript's Date object is already timezone-aware (stores UTC internally),
10
+ * unlike Python's datetime which is naive by default. That's why Django needs
11
+ * timezone.now() but JavaScript doesn't - new Date() already returns UTC.
12
+ *
13
+ * ── Usage ─────────────────────────────────────────────────────────────────────
14
+ *
15
+ * const { Time } = require('millas/core/timezone');
16
+ *
17
+ * // Convert UTC to local timezone for display
18
+ * const local = Time.localtime(new Date());
19
+ *
20
+ * // Parse datetime string as UTC
21
+ * const dt = Time.parse('2026-03-31 20:00:00');
22
+ *
23
+ * // Format for display
24
+ * const formatted = Time.format(new Date(), 'datetime');
25
+ *
26
+ * ── Configuration ─────────────────────────────────────────────────────────────
27
+ *
28
+ * // config/app.js
29
+ * module.exports = {
30
+ * timezone: 'UTC', // Default timezone for display/scheduler
31
+ * useTz: true, // Store/read timestamps as UTC (recommended)
32
+ * };
33
+ */
34
+ class Time {
35
+ /**
36
+ * Convert a UTC datetime to the local timezone configured in config/app.js.
37
+ * Useful for displaying times to users in their expected timezone.
38
+ *
39
+ * @param {Date} dt - UTC datetime
40
+ * @returns {Date} datetime in local timezone
41
+ */
42
+ static localtime(dt) {
43
+ if (!(dt instanceof Date)) {
44
+ throw new TypeError('localtime() requires a Date object');
45
+ }
46
+
47
+ const timezone = this.getTimezone();
48
+ if (timezone === 'UTC') return dt;
49
+
50
+ // For non-UTC timezones, we need to calculate the offset
51
+ // This is a simplified implementation - for production use,
52
+ // consider using a library like date-fns-tz or luxon
53
+ try {
54
+ const formatter = new Intl.DateTimeFormat('en-US', {
55
+ timeZone: timezone,
56
+ year: 'numeric',
57
+ month: '2-digit',
58
+ day: '2-digit',
59
+ hour: '2-digit',
60
+ minute: '2-digit',
61
+ second: '2-digit',
62
+ hour12: false,
63
+ });
64
+
65
+ const parts = formatter.formatToParts(dt);
66
+ const values = {};
67
+ for (const part of parts) {
68
+ if (part.type !== 'literal') values[part.type] = part.value;
69
+ }
70
+
71
+ return new Date(
72
+ `${values.year}-${values.month}-${values.day}T${values.hour}:${values.minute}:${values.second}`
73
+ );
74
+ } catch (err) {
75
+ // Fallback if timezone is invalid
76
+ return dt;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Convert a naive datetime to timezone-aware datetime.
82
+ * If useTz=true, assumes the naive datetime is in UTC.
83
+ * If useTz=false, assumes the naive datetime is in local timezone.
84
+ *
85
+ * @param {Date} dt - naive datetime
86
+ * @returns {Date} timezone-aware datetime
87
+ */
88
+ static makeAware(dt) {
89
+ if (!(dt instanceof Date)) {
90
+ throw new TypeError('makeAware() requires a Date object');
91
+ }
92
+
93
+ // If already has timezone info (ISO string with Z or offset), return as-is
94
+ const isoStr = dt.toISOString();
95
+ if (isoStr.includes('Z') || isoStr.match(/[+-]\d{2}:\d{2}$/)) {
96
+ return dt;
97
+ }
98
+
99
+ // If useTz=true, treat as UTC
100
+ if (this.isUseTzEnabled()) {
101
+ // Parse as UTC by appending Z
102
+ const str = dt.toISOString().replace('Z', '');
103
+ return new Date(str + 'Z');
104
+ }
105
+
106
+ // If useTz=false, treat as local time
107
+ return dt;
108
+ }
109
+
110
+ /**
111
+ * Convert a timezone-aware datetime to naive datetime.
112
+ * Strips timezone information, keeping the same wall-clock time.
113
+ *
114
+ * @param {Date} dt - timezone-aware datetime
115
+ * @returns {Date} naive datetime
116
+ */
117
+ static makeNaive(dt) {
118
+ if (!(dt instanceof Date)) {
119
+ throw new TypeError('makeNaive() requires a Date object');
120
+ }
121
+
122
+ // Create a new Date with the same components but no timezone
123
+ const year = dt.getUTCFullYear();
124
+ const month = dt.getUTCMonth();
125
+ const day = dt.getUTCDate();
126
+ const hours = dt.getUTCHours();
127
+ const minutes = dt.getUTCMinutes();
128
+ const seconds = dt.getUTCSeconds();
129
+ const ms = dt.getUTCMilliseconds();
130
+
131
+ return new Date(year, month, day, hours, minutes, seconds, ms);
132
+ }
133
+
134
+ /**
135
+ * Get the configured timezone from config/app.js.
136
+ *
137
+ * @returns {string} timezone (e.g., 'UTC', 'Africa/Nairobi')
138
+ */
139
+ static getTimezone() {
140
+ try {
141
+ const appConfig = require(process.cwd() + '/config/app.js');
142
+ return appConfig.timezone || 'UTC';
143
+ } catch {
144
+ return 'UTC';
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Check if USE_TZ is enabled (timezone awareness).
150
+ *
151
+ * @returns {boolean}
152
+ */
153
+ static isUseTzEnabled() {
154
+ try {
155
+ const appConfig = require(process.cwd() + '/config/app.js');
156
+ return appConfig.useTz !== false; // Default to true
157
+ } catch {
158
+ return true;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Format a datetime for display in the configured timezone.
164
+ *
165
+ * @param {Date} dt - datetime to format
166
+ * @param {string} format - format string (default: ISO)
167
+ * @returns {string} formatted datetime
168
+ */
169
+ static format(dt, format = 'iso') {
170
+ if (!(dt instanceof Date)) {
171
+ throw new TypeError('format() requires a Date object');
172
+ }
173
+
174
+ const local = this.localtime(dt);
175
+
176
+ switch (format) {
177
+ case 'iso':
178
+ return local.toISOString();
179
+ case 'date':
180
+ return local.toISOString().split('T')[0];
181
+ case 'time':
182
+ return local.toISOString().split('T')[1].split('.')[0];
183
+ case 'datetime':
184
+ return local.toISOString().replace('T', ' ').split('.')[0];
185
+ default:
186
+ return local.toISOString();
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Parse a datetime string, treating it as UTC if useTz=true.
192
+ *
193
+ * @param {string} str - datetime string
194
+ * @returns {Date} parsed datetime
195
+ */
196
+ static parse(str) {
197
+ if (typeof str !== 'string') {
198
+ throw new TypeError('parse() requires a string');
199
+ }
200
+
201
+ // If already has timezone info, parse normally
202
+ if (str.includes('Z') || str.match(/[+-]\d{2}:\d{2}$/)) {
203
+ return new Date(str);
204
+ }
205
+
206
+ // If useTz=true, treat as UTC
207
+ if (this.isUseTzEnabled()) {
208
+ return new Date(str + 'Z');
209
+ }
210
+
211
+ // If useTz=false, parse as local time
212
+ return new Date(str);
213
+ }
214
+ }
215
+
216
+ module.exports = Time;